Started full rewrite; wrote first DirectorySpec
parent
75b5b5b316
commit
bb33f7cbbc
2
Makefile
2
Makefile
|
@ -8,7 +8,7 @@ PYTHON=python3.8
|
||||||
'$(PYTHON)' -m venv .venv
|
'$(PYTHON)' -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
venv: .venv/bin/python
|
venv: .venv/bin/activate
|
||||||
.PHONY: venv
|
.PHONY: venv
|
||||||
|
|
||||||
format: venv
|
format: venv
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from specs import parse_specs_file
|
from parser import read_specs_file
|
||||||
|
|
||||||
|
|
||||||
# This just displays the error type and message, not the stack trace
|
# This just displays the error type and message, not the stack trace
|
||||||
|
@ -20,6 +20,7 @@ parser.add_argument(
|
||||||
"--file",
|
"--file",
|
||||||
action="append",
|
action="append",
|
||||||
dest="file",
|
dest="file",
|
||||||
|
required=True,
|
||||||
help="File containing spec definitions.",
|
help="File containing spec definitions.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -28,7 +29,7 @@ parser.add_argument(
|
||||||
action="store_const",
|
action="store_const",
|
||||||
const=True,
|
const=True,
|
||||||
default=False,
|
default=False,
|
||||||
help="Print out the parsed specs as JSON " "and exit",
|
help="Print out the parsed specs as JSON and exit",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"spec", nargs="*", help="The specs to process. Defaults to all."
|
"spec", nargs="*", help="The specs to process. Defaults to all."
|
||||||
|
@ -36,11 +37,11 @@ parser.add_argument(
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
specs = sum([parse_specs_file(path) for path in args.file], [])
|
specs = sum([read_specs_file(path) for path in args.file], [])
|
||||||
|
|
||||||
# Filter specs if needed
|
# Filter specs if needed
|
||||||
if args.spec:
|
if args.spec:
|
||||||
specs = filter(lambda s: s.name in args.spec, specs)
|
specs = list(filter(lambda s: s.name in args.spec, specs))
|
||||||
|
|
||||||
# Dump parsed data as json
|
# Dump parsed data as json
|
||||||
if args.json:
|
if args.json:
|
||||||
|
@ -49,7 +50,10 @@ if args.json:
|
||||||
print(json.dumps([spec.to_dict() for spec in specs], indent=4))
|
print(json.dumps([spec.to_dict() for spec in specs], indent=4))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
pass
|
|
||||||
# Run the backups
|
# Run the backups
|
||||||
# for spec in specs:
|
if not specs:
|
||||||
# spec.backup()
|
print("No specs, exiting.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
spec.backup()
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Union
|
||||||
|
from specs import Spec, DirectorySpec
|
||||||
|
|
||||||
|
|
||||||
|
def read_specs_file(path: Union[str, Path]) -> List[Spec]:
|
||||||
|
with open(path, "r") as yaml_file:
|
||||||
|
data = yaml.safe_load(yaml_file, Loader=yaml.FullLoader)
|
||||||
|
|
||||||
|
categories = [("directories", DirectorySpec)]
|
||||||
|
|
||||||
|
specs = []
|
||||||
|
|
||||||
|
for key, class_name in categories:
|
||||||
|
if key not in data["specs"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check what defaults are present
|
||||||
|
defaults = []
|
||||||
|
if data.get("defaults"):
|
||||||
|
if data["defaults"].get("all"):
|
||||||
|
defaults.append(data["defaults"]["all"])
|
||||||
|
|
||||||
|
if data["defaults"].get(key):
|
||||||
|
defaults.append(data["defaults"][key])
|
||||||
|
|
||||||
|
specs.extend(
|
||||||
|
[
|
||||||
|
class_name.from_dict(name, spec, *defaults)
|
||||||
|
for name, spec in data["specs"][key].items()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return specs
|
|
@ -0,0 +1,47 @@
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidKeyError(Exception):
|
||||||
|
def __init__(self, key):
|
||||||
|
self.message = "Invalid key: {}".format(key)
|
||||||
|
|
||||||
|
super().__init__(key)
|
||||||
|
|
||||||
|
|
||||||
|
class MissingKeyError(Exception):
|
||||||
|
def __init__(self, key):
|
||||||
|
self.message = "Missing key: {}".format(key)
|
||||||
|
|
||||||
|
super().__init__(key)
|
||||||
|
|
||||||
|
|
||||||
|
def combine(data: Dict, skel: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Compare a dict with a given skeleton dict, and fill in default values where
|
||||||
|
needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# First, check for illegal keys
|
||||||
|
for key in data:
|
||||||
|
if key not in skel:
|
||||||
|
raise InvalidKeyError(key)
|
||||||
|
|
||||||
|
# Then, check the default values
|
||||||
|
for key, value in skel.items():
|
||||||
|
if key not in data:
|
||||||
|
# Raise error if there's not default value
|
||||||
|
if value is None:
|
||||||
|
raise MissingKeyError(key)
|
||||||
|
|
||||||
|
# Replace with default value
|
||||||
|
data[key] = value
|
||||||
|
|
||||||
|
# Error if value is not same type as default value
|
||||||
|
elif type(data[key]) != type(value) and value is not None:
|
||||||
|
raise TypeError("Invalid value type")
|
||||||
|
|
||||||
|
# Recurse into dicts
|
||||||
|
elif type(value) == dict:
|
||||||
|
data[key] = combine_with_skeleton(data[key], value)
|
||||||
|
|
||||||
|
return data
|
|
@ -1,2 +1,2 @@
|
||||||
from .specs import Spec
|
from .spec import Spec
|
||||||
from .parser import parse_specs_file
|
from .directory import DirectorySpec
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
from .spec import Spec
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Union
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DirectorySpec(Spec):
|
||||||
|
"""
|
||||||
|
A spec for backing up a local directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__SKEL = {
|
||||||
|
"name": None,
|
||||||
|
"source": None,
|
||||||
|
"destination": None,
|
||||||
|
"limit": None,
|
||||||
|
"notifier": None,
|
||||||
|
"command": "tar -czf '{destination}/{filename}' .",
|
||||||
|
"extension": "tar.gz",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
source: Union[str, Path],
|
||||||
|
destination: Union[str, Path],
|
||||||
|
limit: int,
|
||||||
|
command: str,
|
||||||
|
extension: str,
|
||||||
|
notifier=None,
|
||||||
|
):
|
||||||
|
super().__init__(name, destination, limit, extension, notifier)
|
||||||
|
|
||||||
|
self.source = source if type(source) == Path else Path(source)
|
||||||
|
|
||||||
|
# Check existence of source directory
|
||||||
|
if not self.source.exists() or not self.source.is_dir():
|
||||||
|
raise NotADirectoryError(
|
||||||
|
"{} doesn't exist or isn't a directory.".format(self.source)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.command = command
|
||||||
|
|
||||||
|
def backup(self):
|
||||||
|
# Remove excess backups
|
||||||
|
self.remove_backups()
|
||||||
|
|
||||||
|
# Run actual backup command
|
||||||
|
filename = "{}.{}".format(
|
||||||
|
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), self.extension
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO add logging stuff
|
||||||
|
process = subprocess.run(
|
||||||
|
self.command.format(
|
||||||
|
destination=self.destination,
|
||||||
|
filename=filename,
|
||||||
|
),
|
||||||
|
cwd=self.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.notifier:
|
||||||
|
self.notifier.notify(process.returncode)
|
|
@ -1,96 +0,0 @@
|
||||||
import yaml
|
|
||||||
from pathlib import Path
|
|
||||||
from specs import Spec
|
|
||||||
from typing import List, Dict
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidKeyError(Exception):
|
|
||||||
def __init__(self, key):
|
|
||||||
message = "Invalid key: {}".format(key)
|
|
||||||
|
|
||||||
super().__init__(key)
|
|
||||||
|
|
||||||
|
|
||||||
class MissingKeyError(Exception):
|
|
||||||
def __init__(self, key):
|
|
||||||
message = "Missing key: {}".format(key)
|
|
||||||
|
|
||||||
super().__init__(key)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_specs_file(path: Path) -> List[Spec]:
|
|
||||||
"""
|
|
||||||
Parse a YAML file defining backup specs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: path to the specs file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of specs
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Skeleton of a spec config
|
|
||||||
# If a value is None, this means it doesn't have a default value and must be
|
|
||||||
# defined
|
|
||||||
spec_skel = {
|
|
||||||
"source": None,
|
|
||||||
"destination": None,
|
|
||||||
"limit": None,
|
|
||||||
"volume": False,
|
|
||||||
"notify": {"title": "Backup Notification", "events": ["failure"]},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read YAML file
|
|
||||||
with open(path, "r") as yaml_file:
|
|
||||||
data = yaml.load(yaml_file, Loader=yaml.Loader)
|
|
||||||
|
|
||||||
# Check specs section exists
|
|
||||||
if "specs" not in data:
|
|
||||||
raise MissingKeyError("specs")
|
|
||||||
|
|
||||||
# Allow for default notify settings
|
|
||||||
if "notify" in data:
|
|
||||||
spec_skel["notify"] = data["notify"]
|
|
||||||
|
|
||||||
specs = []
|
|
||||||
# Check format for each spec
|
|
||||||
for key in data["specs"]:
|
|
||||||
specs.append(
|
|
||||||
Spec.from_dict(
|
|
||||||
key, combine_with_skeleton(data["specs"][key], spec_skel)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return specs
|
|
||||||
|
|
||||||
|
|
||||||
def combine_with_skeleton(data: Dict, skel: Dict) -> Dict:
|
|
||||||
"""
|
|
||||||
Compare a dict with a given skeleton dict, and fill in default values where
|
|
||||||
needed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# First, check for illegal keys
|
|
||||||
for key in data:
|
|
||||||
if key not in skel:
|
|
||||||
raise InvalidKeyError(key)
|
|
||||||
|
|
||||||
# Then, check the default values
|
|
||||||
for key, value in skel.items():
|
|
||||||
if key not in data:
|
|
||||||
# Raise error if there's not default value
|
|
||||||
if value is None:
|
|
||||||
raise MissingKeyError(key)
|
|
||||||
|
|
||||||
# Replace with default value
|
|
||||||
data[key] = value
|
|
||||||
|
|
||||||
# Error if value is not same type as default value
|
|
||||||
elif type(data[key]) != type(value) and value is not None:
|
|
||||||
raise TypeError("Invalid value type")
|
|
||||||
|
|
||||||
# Recurse into dicts
|
|
||||||
elif type(value) == dict:
|
|
||||||
data[key] = combine_with_skeleton(data[key], value)
|
|
||||||
|
|
||||||
return data
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Union, Dict
|
||||||
|
import skeleton
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Spec:
|
||||||
|
"""
|
||||||
|
Base class for all other spec types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__SKEL = {
|
||||||
|
"name": None,
|
||||||
|
"destination": None,
|
||||||
|
"limit": None,
|
||||||
|
"notifier": None,
|
||||||
|
"extension": "tar.gz",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
destination: Union[Path, str],
|
||||||
|
limit: int,
|
||||||
|
extension: str,
|
||||||
|
notifier=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
name: name of the spec
|
||||||
|
destination: directory where the backups shall reside
|
||||||
|
limit: max amount of backups
|
||||||
|
notifier: notifier object
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.destination = (
|
||||||
|
destination if type(destination) == Path else Path(destination)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check existence of destination folder
|
||||||
|
if not self.destination.exists() or not self.destination.is_dir():
|
||||||
|
raise NotADirectoryError(
|
||||||
|
"{} doesn't exist or isn't a directory.".format(
|
||||||
|
self.destination
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.limit = limit
|
||||||
|
self.notifier = notifier
|
||||||
|
self.extension = extension
|
||||||
|
|
||||||
|
def remove_backups(self):
|
||||||
|
"""
|
||||||
|
Remove all backups exceeding the limit
|
||||||
|
"""
|
||||||
|
|
||||||
|
files = sorted(
|
||||||
|
self.destination.glob(self.extension),
|
||||||
|
key=os.path.getmtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(files) >= self.limit:
|
||||||
|
for path in files[self.limit - 1 :]:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def backup(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, name, obj: Dict, *defaults: Dict) -> Spec:
|
||||||
|
# Combine defaults with skeleton, creating new skeleton
|
||||||
|
skel = cls.__SKEL
|
||||||
|
|
||||||
|
for default in defaults:
|
||||||
|
skel = skeleton.combine(defaults, skel)
|
||||||
|
|
||||||
|
# Then, combine actual values with new skeleton
|
||||||
|
obj = skeleton.combine(obj, skel)
|
||||||
|
|
||||||
|
return cls(name, **obj)
|
|
@ -1,142 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
import requests
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class Spec:
|
|
||||||
# Base class has no skeleton
|
|
||||||
__SKELETON = {}
|
|
||||||
|
|
||||||
def __init__(self, name, destination, limit, title, events=None):
|
|
||||||
self.name = name
|
|
||||||
self.destination = Path(destination)
|
|
||||||
self.limit = limit
|
|
||||||
self.title = title
|
|
||||||
self.events = [] if events is None else events
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
return {
|
|
||||||
"name": self.name,
|
|
||||||
"destination": str(self.destination),
|
|
||||||
"limit": self.limit,
|
|
||||||
"notify": {"title": self.title, "events": self.events},
|
|
||||||
}
|
|
||||||
|
|
||||||
def backup(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def remove_redundant(self):
|
|
||||||
tarballs = sorted(
|
|
||||||
self.destination.glob("*.tar.gz"),
|
|
||||||
key=os.path.getmtime,
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(tarballs) >= self.limit:
|
|
||||||
for path in tarballs[self.limit - 1 :]:
|
|
||||||
path.unlink()
|
|
||||||
|
|
||||||
def notify(self, status_code):
|
|
||||||
if status_code:
|
|
||||||
if "failure" not in self.events:
|
|
||||||
return
|
|
||||||
|
|
||||||
message = "backup for {} failed.".format(self.name)
|
|
||||||
|
|
||||||
else:
|
|
||||||
if "success" not in self.events:
|
|
||||||
return
|
|
||||||
|
|
||||||
message = "backup for {} succeeded.".format(self.name)
|
|
||||||
|
|
||||||
# Read API key from env vars
|
|
||||||
try:
|
|
||||||
key = os.environ["IFTTT_API_KEY"]
|
|
||||||
|
|
||||||
# Don't send notification if there's not API key defined
|
|
||||||
except KeyError:
|
|
||||||
return
|
|
||||||
|
|
||||||
url = "https://maker.ifttt.com/trigger/{}/with/key/{}".format(
|
|
||||||
"phone_notifications", key
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {"value1": self.title, "value2": message}
|
|
||||||
|
|
||||||
requests.post(url, data=data)
|
|
||||||
|
|
||||||
def get_filename(self):
|
|
||||||
return "{}_{}.tar.gz".format(
|
|
||||||
self.name, datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(name, data) -> "Specification":
|
|
||||||
if data.get("volume", False):
|
|
||||||
return VolumeSpec.from_dict(name, data)
|
|
||||||
|
|
||||||
return DirSpec.from_dict(name, data)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_file(path: str):
|
|
||||||
with open(path, "r") as yaml_file:
|
|
||||||
data = yaml.load(yaml_file, Loader=yaml.Loader)
|
|
||||||
|
|
||||||
return [
|
|
||||||
Spec.from_dict(name, info) for name, info in data["specs"].items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class DirSpec(Spec):
|
|
||||||
def __init__(self, name, source, destination, limit, title, events=None):
|
|
||||||
super().__init__(name, destination, limit, title, events)
|
|
||||||
|
|
||||||
self.source = Path(source)
|
|
||||||
|
|
||||||
def backup(self):
|
|
||||||
self.remove_redundant()
|
|
||||||
|
|
||||||
status_code = os.system(
|
|
||||||
"tar -C '{}' -czf '{}' -- .".format(
|
|
||||||
self.source, self.destination / self.get_filename()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.notify(status_code)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(name, data):
|
|
||||||
return DirSpec(
|
|
||||||
name,
|
|
||||||
data["source"],
|
|
||||||
data["destination"],
|
|
||||||
data["limit"],
|
|
||||||
data["notify"]["title"],
|
|
||||||
data["notify"]["events"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeSpec(Spec):
|
|
||||||
def __init__(self, name, volume, destination, limit, title, events=None):
|
|
||||||
super().__init__(name, destination, limit, title, events)
|
|
||||||
|
|
||||||
self.volume = volume
|
|
||||||
|
|
||||||
def backup(self):
|
|
||||||
status_code = os.system(
|
|
||||||
"docker run --rm -v '{}:/from' -v '{}:/to' alpine:latest "
|
|
||||||
"tar -C /from -czf '/to/{}' -- .".format(
|
|
||||||
self.volume, self.destination, self.get_filename()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(name, data):
|
|
||||||
return VolumeSpec(
|
|
||||||
name,
|
|
||||||
data["source"],
|
|
||||||
data["destination"],
|
|
||||||
data["limit"],
|
|
||||||
data["notify"]["title"],
|
|
||||||
data["notify"]["events"],
|
|
||||||
)
|
|
Loading…
Reference in New Issue