Skip to content

Commit

Permalink
implement keep; delete old backups
Browse files Browse the repository at this point in the history
  • Loading branch information
eimrek committed Nov 1, 2023
1 parent a701374 commit 15e9982
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 54 deletions.
130 changes: 85 additions & 45 deletions disk_objectstore/backup_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
Utilities to back up a container.
"""

import datetime
import logging
import random
import shutil
import sqlite3
import string
import subprocess
import tempfile
from pathlib import Path
Expand Down Expand Up @@ -34,32 +37,32 @@ def is_exe_found(exe: str) -> bool:
return shutil.which(exe) is not None

Check warning on line 37 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L37

Added line #L37 was not covered by tests


def run_cmd(args: list, remote: Optional[str] = None, check: bool = True) -> bool:
def run_cmd(args: list, remote: Optional[str] = None):
"""
Run a command locally or remotely.
"""
all_args = args[:]
if remote:
all_args = ["ssh", remote] + all_args

Check warning on line 46 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L44-L46

Added lines #L44 - L46 were not covered by tests

try:
res = subprocess.run(all_args, capture_output=True, text=True, check=check)
except subprocess.CalledProcessError as exc:
_log("Error: " + str(exc))
return False
res = subprocess.run(all_args, capture_output=True, text=True, check=False)
exit_code = res.returncode

Check warning on line 49 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L48-L49

Added lines #L48 - L49 were not covered by tests

_log(f"stdout: {all_args}\n{res.stdout}")
_log(f"stderr: {all_args}\n{res.stderr}")
_log(

Check warning on line 51 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L51

Added line #L51 was not covered by tests
f"Command: {all_args}\n"
f" Exit Code: {exit_code}\n"
f" stdout/stderr: {res.stdout}\n{res.stderr}"
)

success = not bool(res.returncode)
success = exit_code == 0

Check warning on line 57 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L57

Added line #L57 was not covered by tests

return success
return success, res.stdout

Check warning on line 59 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L59

Added line #L59 was not covered by tests


def check_if_remote_accessible(remote: str) -> bool:
"""Check if remote host is accessible via ssh"""
_log(f"Checking if '{remote}' is accessible...", end="")
success = run_cmd(["exit"], remote=remote)
_log(f"Checking if '{remote}' is accessible...")
success = run_cmd(["exit"], remote=remote)[0]
if not success:
_log(f"Error: Remote '{remote}' is not accessible!")
return False
Expand All @@ -69,7 +72,7 @@ def check_if_remote_accessible(remote: str) -> bool:

def check_path_exists(path: Path, remote: Optional[str] = None) -> bool:
cmd = ["[", "-e", str(path), "]"]
return run_cmd(cmd, remote=remote, check=False)
return run_cmd(cmd, remote=remote)[0]

Check warning on line 75 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L74-L75

Added lines #L74 - L75 were not covered by tests


def call_rsync( # pylint: disable=too-many-arguments
Expand Down Expand Up @@ -127,25 +130,33 @@ def call_rsync( # pylint: disable=too-many-arguments
except subprocess.CalledProcessError as exc:
_log(f"Error: {exc}")
return False
exit_code = res.returncode
_log(

Check warning on line 134 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L128-L134

Added lines #L128 - L134 were not covered by tests
f"Command: {all_args}\n"
f" Exit Code: {exit_code}\n"
f" stdout/stderr: {res.stdout}\n{res.stderr}"
)

_log(f"stdout: {all_args}\n{res.stdout}")
_log(f"stderr: {all_args}\n{res.stderr}")

success = not bool(res.returncode)
success = exit_code == 0

Check warning on line 140 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L140

Added line #L140 was not covered by tests

return success

Check warning on line 142 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L142

Added line #L142 was not covered by tests


def validate_inputs(
path: Path,
remote: Optional[str] = None,
remote: Optional[str],
keep: int,
rsync_exe: str = "rsync",
) -> bool:
"""Validate inputs to the backup cli command
:return:
True if validation passes, False otherwise.
"""
if keep < 0:
_log("Error: keep variable can't be negative!")
return False

Check warning on line 158 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L156-L158

Added lines #L156 - L158 were not covered by tests

if remote:
if not check_if_remote_accessible(remote):
return False

Check warning on line 162 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L160-L162

Added lines #L160 - L162 were not covered by tests
Expand All @@ -157,7 +168,7 @@ def validate_inputs(
path_exists = check_path_exists(path, remote)

Check warning on line 168 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L168

Added line #L168 was not covered by tests

if not path_exists:
success = run_cmd(["mkdir", str(path)], remote=remote)
success = run_cmd(["mkdir", str(path)], remote=remote)[0]
if not success:
_log(f"Error: Couldn't access/create '{str(path)}'!")
return False

Check warning on line 174 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L170-L174

Added lines #L170 - L174 were not covered by tests
Expand Down Expand Up @@ -261,18 +272,49 @@ def backup_container( # pylint: disable=too-many-return-statements, too-many-br
return True

Check warning on line 272 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L272

Added line #L272 was not covered by tests


def delete_old_backups(path: Path, remote: Optional[str] = None, keep: int = 1) -> bool:
"""Get all folders matching the backup pattern, and delete oldest ones."""
success, stdout = run_cmd(

Check warning on line 277 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L277

Added line #L277 was not covered by tests
[
"find",
str(path),
"-maxdepth",
"1",
"-type",
"d",
"-name",
"backup_*_*",
"-print",
],
remote=remote,
)
if not success:
return False

Check warning on line 292 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L291-L292

Added lines #L291 - L292 were not covered by tests

sorted_folders = sorted(stdout.splitlines())
to_delete = sorted_folders[: -(keep + 1)]
for folder in to_delete:
success = run_cmd(["rm", "-rf", folder], remote=remote)[0]
if success:
_log(f"Deleted old backup: {folder}")

Check warning on line 299 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L294-L299

Added lines #L294 - L299 were not covered by tests
else:
_log(f"Warning: couldn't delete old backup: {folder}")
return True

Check warning on line 302 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L301-L302

Added lines #L301 - L302 were not covered by tests


def backup_auto_folders(
container: Container,
path: Path,
remote: Optional[str] = None,
keep: int = 1,
rsync_exe: str = "rsync",
):
"""Create a backup, managing live and previous backup folders automatically
The running backup is done to `<path>/live-backup`. When it completes, it is moved to
the final path: `<path>/last-backup`. This done so that the last backup wouldn't be
corrupted, in case the live one crashes or gets interrupted. Rsync `link-dest` is used between
the two folders to keep the backups incremental and performant.
the final path: `<path>/backup_<timestamp>_<randstr>` and the symlink `<path>/last-backup will
be set to point to it. Rsync `link-dest` is used between live-backup and last-backup
to keep the backups incremental and performant.
:param path:
Path to where the backup will be created. If 'remote' is specified, must be an absolute path,
Expand All @@ -282,46 +324,44 @@ def backup_auto_folders(
Remote host of the backup location. 'ssh' executable is called via subprocess and therefore remote
hosts configured for it are supported (e.g. via .ssh/config file).
:param kwargs:
* Executable paths if not default, e.g. 'rsync'
:return:
True is successful and False if unsuccessful.
"""

live_folder = path / "live-backup"
last_folder = path / "last-backup"
last_symlink = path / "last-backup"

Check warning on line 332 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L331-L332

Added lines #L331 - L332 were not covered by tests

prev_exists = check_path_exists(last_folder, remote)
prev_exists = check_path_exists(last_symlink, remote)

Check warning on line 334 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L334

Added line #L334 was not covered by tests

success = backup_container(

Check warning on line 336 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L336

Added line #L336 was not covered by tests
container,
live_folder,
remote=remote,
prev_backup=last_folder if prev_exists else None,
prev_backup=last_symlink if prev_exists else None,
rsync_exe=rsync_exe,
)
if not success:
return False

Check warning on line 344 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L343-L344

Added lines #L343 - L344 were not covered by tests

# move live-backup -> last-backup in a safe manner
# (such that if the process stops at any point, that we wouldn't lose data)
# step 1: last-backup -> last-backup-old
if prev_exists:
success = run_cmd(
["mv", str(last_folder), str(last_folder) + "-old"], remote=remote
)
if not success:
return False
# step 2: live-backup -> last-backup
success = run_cmd(["mv", str(live_folder), str(last_folder)], remote=remote)
# move live-backup -> backup_<timestamp>_<randstr>
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
randstr = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
folder_name = f"backup_{timestamp}_{randstr}"

Check warning on line 349 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L347-L349

Added lines #L347 - L349 were not covered by tests

success = run_cmd(["mv", str(live_folder), str(path / folder_name)], remote=remote)[

Check warning on line 351 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L351

Added line #L351 was not covered by tests
0
]
if not success:
return False

Check warning on line 355 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L354-L355

Added lines #L354 - L355 were not covered by tests
# step 3: remote last-backup-old
if prev_exists:
success = run_cmd(["rm", "-rf", str(last_folder) + "-old"], remote=remote)
if not success:
return False

_log(f"Backup moved from '{str(live_folder)}' to '{str(last_folder)}'.")
# update last-backup symlink
success = run_cmd(

Check warning on line 358 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L358

Added line #L358 was not covered by tests
["ln", "-sfn", str(folder_name), str(last_symlink)], remote=remote
)[0]
if not success:
return False
_log(f"Backup moved from '{str(live_folder)}' to '{str(path / folder_name)}'.")

Check warning on line 363 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L361-L363

Added lines #L361 - L363 were not covered by tests

delete_old_backups(path, remote=remote, keep=keep)

Check warning on line 365 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L365

Added line #L365 was not covered by tests

return True

Check warning on line 367 in disk_objectstore/backup_utils.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/backup_utils.py#L367

Added line #L367 was not covered by tests
23 changes: 14 additions & 9 deletions disk_objectstore/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,11 @@ def optimize(

@main.command("backup")
@click.argument("dest", nargs=1, type=click.Path())
# @click.option(
# "--keep",
# default=1,
# help="Number of previous backups to keep in the destination.",
# )
@click.option(
"--keep",
default=1,
help="Number of previous backups to keep in the destination. (default: 1)",
)
@click.option(
"--rsync_exe",
default="rsync",
Expand All @@ -201,9 +201,11 @@ def optimize(
def backup(
dostore: ContainerContext,
dest: str,
keep: int,
rsync_exe: str,
):
"""Create a backup of the container to a subfolder `last-backup` of the destination location DEST.
"""Create a backup of the container to destination location DEST, in a subfolder
backup_<timestamp>_<randstr> and point a symlink called `last-backup` to it.
NOTE: This is safe to run while the container is being used.
Expand All @@ -215,9 +217,8 @@ def backup(
by OpenSSH, such as adding configuration options to ~/.ssh/config (e.g. to allow for passwordless
login - recommended, since this script might ask multiple times for the password).
NOTE: 'rsync' and other UNIX-specific commands are called, thus the command will not work on
NOTE: 'rsync' and other UNIX-specific commands are called, thus the command will not work on
non-UNIX environments.
"""

try:
Expand All @@ -226,12 +227,16 @@ def backup(
click.echo("Unsupported destination.")
return False

Check warning on line 228 in disk_objectstore/cli.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/cli.py#L224-L228

Added lines #L224 - L228 were not covered by tests

backup_utils.validate_inputs(path, remote=remote, rsync_exe=rsync_exe)
success = backup_utils.validate_inputs(path, remote, keep, rsync_exe=rsync_exe)
if not success:
click.echo("Input validation failed.")
return False

Check warning on line 233 in disk_objectstore/cli.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/cli.py#L230-L233

Added lines #L230 - L233 were not covered by tests

with dostore.container as container:
return backup_utils.backup_auto_folders(

Check warning on line 236 in disk_objectstore/cli.py

View check run for this annotation

Codecov / codecov/patch

disk_objectstore/cli.py#L235-L236

Added lines #L235 - L236 were not covered by tests
container,
path,
remote=remote,
keep=keep,
rsync_exe=rsync_exe,
)

0 comments on commit 15e9982

Please sign in to comment.