Skip to content
Snippets Groups Projects
Commit 444ebde2 authored by Radoslav Bodó's avatar Radoslav Bodó
Browse files

rwm: backup_all_cmd, refactoring and tests

parent 276698ec
No related branches found
No related tags found
No related merge requests found
[MESSAGES]
disable = logging-fstring-interpolation
[FORMAT] [FORMAT]
max-line-length=150 max-line-length=150
[TYPECHECK]
ignored-classes=SQLAlchemy, sqlalchemy.orm.scoping.scoped_session
[SIMILARITIES] [SIMILARITIES]
min-similarity-lines=8 min-similarity-lines=8
ignore-imports=yes ignore-imports=yes
\ No newline at end of file
all: lint all: lint
install: install:
apt-get -y install awscli make python3-cryptography rclone restic yamllint apt-get -y install awscli make python3-cryptography python3-tabulate rclone restic yamllint
venv: venv:
apt-get -y install python3-venv apt-get -y install python3-venv
......
...@@ -72,6 +72,7 @@ s3transfer==0.10.1 ...@@ -72,6 +72,7 @@ s3transfer==0.10.1
sarif-om==1.0.4 sarif-om==1.0.4
six==1.16.0 six==1.16.0
sympy==1.12 sympy==1.12
tabulate==0.9.0
tomlkit==0.12.4 tomlkit==0.12.4
typing_extensions==4.10.0 typing_extensions==4.10.0
urllib3==2.2.1 urllib3==2.2.1
......
# runtime # runtime
cryptography cryptography
tabulate
# dev # dev
flake8 flake8
......
...@@ -2,17 +2,20 @@ ...@@ -2,17 +2,20 @@
"""rwm, restic/s3 worm manager""" """rwm, restic/s3 worm manager"""
import base64 import base64
import dataclasses
import logging import logging
import os import os
import shlex import shlex
import subprocess import subprocess
import sys import sys
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime
from pathlib import Path from pathlib import Path
import yaml import yaml
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from tabulate import tabulate
__version__ = "0.2" __version__ = "0.2"
...@@ -74,6 +77,28 @@ def rclone_obscure_password(plaintext, iv=None): ...@@ -74,6 +77,28 @@ def rclone_obscure_password(plaintext, iv=None):
return base64.urlsafe_b64encode(data).decode().rstrip("=") return base64.urlsafe_b64encode(data).decode().rstrip("=")
@dataclasses.dataclass
class BackupResult:
"""backup results data container"""
name: str
returncode: int
time_start: datetime
time_end: datetime
def to_dict(self):
"""dict serializer"""
return {
"ident": "RESULT",
"name": self.name,
"status": "OK" if self.returncode == 0 else "ERROR",
"returncode": self.returncode,
"backup_start": self.time_start.isoformat(),
"backup_time": str(self.time_end-self.time_start),
}
class RWM: class RWM:
"""rwm impl""" """rwm impl"""
...@@ -148,46 +173,92 @@ class RWM: ...@@ -148,46 +173,92 @@ class RWM:
} }
return run_command(["restic"] + args, env=env) return run_command(["restic"] + args, env=env)
def backup(self, name): def restic_autoinit(self):
"""do restic backup from config""" """runs restic init"""
if self.restic_cmd(["cat", "config"]).returncode != 0: logger.info("run restic_autoinit")
if (proc := self.restic_cmd(["init"])).returncode != 0: if (proc := self.restic_cmd(["cat", "config"])).returncode != 0:
logger.error("failed to autoinitialize restic repository") proc = self.restic_cmd(["init"])
return proc return proc
def restic_backup(self, name):
"""runs restic backup by name"""
logger.info(f"run restic_backup {name}")
conf = self.config["RWM_BACKUPS"][name] conf = self.config["RWM_BACKUPS"][name]
excludes = []
for item in conf.get("excludes", []):
excludes += ["--exclude", item]
backup_extras = conf.get("backup_extras", [])
cmd_args = ["backup"] + backup_extras + excludes + conf["filesdirs"]
# restic backup return self.restic_cmd(cmd_args)
excludes = conf.get("excludes", [])
excludes = [x for pair in zip(["--exclude"] * len(excludes), excludes) for x in pair]
extras = conf.get("extras", [])
cmd_args = ["backup"] + extras + excludes + conf["filesdirs"]
logger.info("running backup") def restic_forget_prune(self):
backup_proc = self.restic_cmd(cmd_args) """runs forget prune"""
wrap_output(backup_proc)
if backup_proc.returncode != 0:
logger.error("backup failed, %s", backup_proc)
return backup_proc
# restic forget prune logger.info("run restic_forget_prune")
keeps = [] keeps = []
for key, val in self.config.get("RWM_RETENTION", {}).items(): for key, val in self.config.get("RWM_RETENTION", {}).items():
keeps += [f"--{key}", val] keeps += [f"--{key}", val]
if not keeps:
logger.error("no retention policy found")
cmd_args = ["forget", "--prune"] + keeps cmd_args = ["forget", "--prune"] + keeps
logger.info("running forget prune") return self.restic_cmd(cmd_args)
forget_proc = self.restic_cmd(cmd_args)
wrap_output(forget_proc) def backup_cmd(self, name):
"""backup command"""
autoinit_proc = self.restic_autoinit()
if autoinit_proc.returncode != 0:
logger.error("restic autoinit failed")
wrap_output(autoinit_proc)
return autoinit_proc
wrap_output(backup_proc := self.restic_backup(name))
if backup_proc.returncode != 0:
logger.error("restic_backup failed")
return backup_proc
wrap_output(forget_proc := self.restic_forget_prune())
if forget_proc.returncode != 0: if forget_proc.returncode != 0:
logger.error("forget prune failed, %s", forget_proc) logger.error("restic_forget_prune failed")
return forget_proc return forget_proc
return backup_proc return backup_proc
def backup_all_cmd(self):
"""backup all command"""
stats = {}
ret = 0
time_start = datetime.now()
autoinit_proc = self.restic_autoinit()
time_end = datetime.now()
if autoinit_proc.returncode != 0:
logger.error("restic autoinit failed")
wrap_output(autoinit_proc)
return autoinit_proc.returncode
stats["_autoinit"] = BackupResult("_autoinit", autoinit_proc.returncode, time_start, time_end)
for name in self.config["RWM_BACKUPS"].keys():
time_start = datetime.now()
wrap_output(backup_proc := self.restic_backup(name))
time_end = datetime.now()
ret |= backup_proc.returncode
stats[name] = BackupResult(name, backup_proc.returncode, time_start, time_end)
if ret == 0:
time_start = datetime.now()
wrap_output(forget_proc := self.restic_forget_prune())
time_end = datetime.now()
ret |= forget_proc.returncode
stats["_forget_prune"] = BackupResult("_forget_prune", forget_proc.returncode, time_start, time_end)
logger.info("rwm backup_all results")
print(tabulate([item.to_dict() for item in stats.values()], headers="keys", numalign="left"))
return ret
def configure_logging(debug): def configure_logging(debug):
"""configure logger""" """configure logger"""
...@@ -212,6 +283,7 @@ def parse_arguments(argv): ...@@ -212,6 +283,7 @@ def parse_arguments(argv):
subparsers = parser.add_subparsers(title="commands", dest="command", required=False) subparsers = parser.add_subparsers(title="commands", dest="command", required=False)
subparsers.add_parser("version", help="show version") subparsers.add_parser("version", help="show version")
aws_cmd_parser = subparsers.add_parser("aws", help="aws command") aws_cmd_parser = subparsers.add_parser("aws", help="aws command")
aws_cmd_parser.add_argument("cmd_args", nargs="*") aws_cmd_parser.add_argument("cmd_args", nargs="*")
rc_cmd_parser = subparsers.add_parser("rclone", help="rclone command") rc_cmd_parser = subparsers.add_parser("rclone", help="rclone command")
...@@ -220,8 +292,10 @@ def parse_arguments(argv): ...@@ -220,8 +292,10 @@ def parse_arguments(argv):
rcc_cmd_parser.add_argument("cmd_args", nargs="*") rcc_cmd_parser.add_argument("cmd_args", nargs="*")
res_cmd_parser = subparsers.add_parser("restic", help="restic command") res_cmd_parser = subparsers.add_parser("restic", help="restic command")
res_cmd_parser.add_argument("cmd_args", nargs="*") res_cmd_parser.add_argument("cmd_args", nargs="*")
backup_cmd_parser = subparsers.add_parser("backup", help="backup command") backup_cmd_parser = subparsers.add_parser("backup", help="backup command")
backup_cmd_parser.add_argument("name", help="backup config name") backup_cmd_parser.add_argument("name", help="backup config name")
subparsers.add_parser("backup_all", help="backup all command")
return parser.parse_args(argv) return parser.parse_args(argv)
...@@ -252,8 +326,11 @@ def main(argv=None): ...@@ -252,8 +326,11 @@ def main(argv=None):
ret = wrap_output(rwmi.rclone_crypt_cmd(args.cmd_args)) ret = wrap_output(rwmi.rclone_crypt_cmd(args.cmd_args))
if args.command == "restic": if args.command == "restic":
ret = wrap_output(rwmi.restic_cmd(args.cmd_args)) ret = wrap_output(rwmi.restic_cmd(args.cmd_args))
if args.command == "backup": if args.command == "backup":
ret = rwmi.backup(args.name).returncode ret = rwmi.backup_cmd(args.name).returncode
if args.command == "backup_all":
ret = rwmi.backup_all_cmd()
logger.info("rwm finished with %s (ret %d)", "success" if ret == 0 else "errors", ret) logger.info("rwm finished with %s (ret %d)", "success" if ret == 0 else "errors", ret)
return ret return ret
......
...@@ -46,12 +46,13 @@ def test_main(tmpworkdir: str): # pylint: disable=unused-argument ...@@ -46,12 +46,13 @@ def test_main(tmpworkdir: str): # pylint: disable=unused-argument
# command branches # command branches
mock = Mock(return_value=CompletedProcess(args='dummy', returncode=0)) mock = Mock(return_value=CompletedProcess(args='dummy', returncode=0))
for item in ["aws", "rclone", "rclone_crypt", "restic"]: for item in ["aws", "rclone", "rclone_crypt", "restic", "backup"]:
with patch.object(rwm.RWM, f"{item}_cmd", mock): with patch.object(rwm.RWM, f"{item}_cmd", mock):
assert rwm_main([item, "dummy"]) == 0 assert rwm_main([item, "dummy"]) == 0
with patch.object(rwm.RWM, "backup", mock): mock = Mock(return_value=0)
assert rwm_main(["backup", "dummy"]) == 0 with patch.object(rwm.RWM, "backup_all_cmd", mock):
assert rwm_main(["backup_all"]) == 0
def test_aws_cmd(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument def test_aws_cmd(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument
...@@ -162,8 +163,8 @@ def _list_files(trwm, snapshot_id): ...@@ -162,8 +163,8 @@ def _list_files(trwm, snapshot_id):
] ]
def test_backup(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument def test_backup_cmd(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument
"""test backup command""" """test backup_cmd command"""
trwm = RWM({ trwm = RWM({
"RWM_S3_ENDPOINT_URL": motoserver, "RWM_S3_ENDPOINT_URL": motoserver,
...@@ -187,7 +188,7 @@ def test_backup(tmpworkdir: str, motoserver: str): # pylint: disable=unused-arg ...@@ -187,7 +188,7 @@ def test_backup(tmpworkdir: str, motoserver: str): # pylint: disable=unused-arg
Path("testdatadir/testdata1.txt").write_text("dummydata", encoding="utf-8") Path("testdatadir/testdata1.txt").write_text("dummydata", encoding="utf-8")
Path("testdatadir/testfile_to_be_ignored").write_text("dummydata", encoding="utf-8") Path("testdatadir/testfile_to_be_ignored").write_text("dummydata", encoding="utf-8")
assert trwm.backup("testcfg").returncode == 0 assert trwm.backup_cmd("testcfg").returncode == 0
snapshots = _list_snapshots(trwm) snapshots = _list_snapshots(trwm)
assert len(snapshots) == 1 assert len(snapshots) == 1
...@@ -195,15 +196,7 @@ def test_backup(tmpworkdir: str, motoserver: str): # pylint: disable=unused-arg ...@@ -195,15 +196,7 @@ def test_backup(tmpworkdir: str, motoserver: str): # pylint: disable=unused-arg
assert "/testdatadir/testdata1.txt" in snapshot_files assert "/testdatadir/testdata1.txt" in snapshot_files
def test_backup_autoinit(): # pylint: disable=unused-argument def test_backup_cmd_excludes(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument
"""mocked error handling test"""
mock = Mock(return_value=CompletedProcess(args='dummy', returncode=1))
with patch.object(rwm.RWM, "restic_cmd", mock):
RWM({}).backup("dummy")
def test_backup_excludes(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument
"""test backup command""" """test backup command"""
trwm = RWM({ trwm = RWM({
...@@ -229,7 +222,7 @@ def test_backup_excludes(tmpworkdir: str, motoserver: str): # pylint: disable=u ...@@ -229,7 +222,7 @@ def test_backup_excludes(tmpworkdir: str, motoserver: str): # pylint: disable=u
Path("testdatadir/proc").mkdir() Path("testdatadir/proc").mkdir()
Path("testdatadir/proc/to_be_also_excluded").write_text("dummydata", encoding="utf-8") Path("testdatadir/proc/to_be_also_excluded").write_text("dummydata", encoding="utf-8")
assert trwm.backup("testcfg").returncode == 0 assert trwm.backup_cmd("testcfg").returncode == 0
snapshots = _list_snapshots(trwm) snapshots = _list_snapshots(trwm)
assert len(snapshots) == 1 assert len(snapshots) == 1
...@@ -238,32 +231,61 @@ def test_backup_excludes(tmpworkdir: str, motoserver: str): # pylint: disable=u ...@@ -238,32 +231,61 @@ def test_backup_excludes(tmpworkdir: str, motoserver: str): # pylint: disable=u
assert "/testdatadir/etc/config2" in snapshot_files assert "/testdatadir/etc/config2" in snapshot_files
def test_backup_error_handling(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument def test_backup_cmd_error_handling(tmpworkdir: str, motoserver: str): # pylint: disable=unused-argument
"""test backup command err cases""" """test backup command err cases"""
mock = Mock(side_effect=[ rwm_conf = {
CompletedProcess(args='dummy', returncode=0), # autoinit
CompletedProcess(args='dummy', returncode=11) # backup
])
with patch.object(rwm.RWM, "restic_cmd", mock):
trwm = RWM({
"RWM_BACKUPS": { "RWM_BACKUPS": {
"dummycfg": {"filesdirs": ["dummydir"]} "dummycfg": {"filesdirs": ["dummydir"]}
} }
}) }
proc = trwm.backup("dummycfg") mock_ok = Mock(return_value=CompletedProcess(args='dummy', returncode=0))
assert proc.returncode == 11 mock_fail = Mock(return_value=CompletedProcess(args='dummy', returncode=11))
mock = Mock(side_effect=[ with patch.object(rwm.RWM, "restic_autoinit", mock_fail):
CompletedProcess(args='dummy', returncode=0), # autoinit assert RWM(rwm_conf).backup_cmd("dummycfg").returncode == 11
CompletedProcess(args='dummy', returncode=0), # backup
CompletedProcess(args='dummy', returncode=12) # forget with (
]) patch.object(rwm.RWM, "restic_autoinit", mock_ok),
with patch.object(rwm.RWM, "restic_cmd", mock): patch.object(rwm.RWM, "restic_backup", mock_fail)
trwm = RWM({ ):
assert RWM(rwm_conf).backup_cmd("dummycfg").returncode == 11
with (
patch.object(rwm.RWM, "restic_autoinit", mock_ok),
patch.object(rwm.RWM, "restic_backup", mock_ok),
patch.object(rwm.RWM, "restic_forget_prune", mock_fail)
):
assert RWM(rwm_conf).backup_cmd("dummycfg").returncode == 11
def test_backup_all_cmd(tmpworkdir: str): # pylint: disable=unused-argument
"""test backup command err cases"""
rwm_conf = {
"RWM_BACKUPS": { "RWM_BACKUPS": {
"dummycfg": {"filesdirs": ["dummydir"]} "dummycfg": {"filesdirs": ["dummydir"]}
} }
}) }
proc = trwm.backup("dummycfg") mock = Mock(return_value=CompletedProcess(args='dummy', returncode=0))
assert proc.returncode == 12
with (
patch.object(rwm.RWM, "restic_autoinit", mock),
patch.object(rwm.RWM, "restic_backup", mock),
patch.object(rwm.RWM, "restic_forget_prune", mock)
):
assert RWM(rwm_conf).backup_all_cmd() == 0
def test_backup_all_cmd_error_handling(tmpworkdir: str): # pylint: disable=unused-argument
"""test backup command err cases"""
rwm_conf = {
"RWM_BACKUPS": {
"dummycfg": {"filesdirs": ["dummydir"]}
}
}
mock_fail = Mock(return_value=CompletedProcess(args='dummy', returncode=11))
with patch.object(rwm.RWM, "restic_autoinit", mock_fail):
assert RWM(rwm_conf).backup_all_cmd() == 11
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment