Skip to content

Commit

Permalink
migrate from go to pyhton
Browse files Browse the repository at this point in the history
  • Loading branch information
makhomed committed Nov 17, 2017
1 parent 6575c91 commit 6fd9659
Show file tree
Hide file tree
Showing 17 changed files with 245 additions and 1,136 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
autosnap.conf
674 changes: 0 additions & 674 deletions LICENSE

This file was deleted.

25 changes: 0 additions & 25 deletions README.md

This file was deleted.

61 changes: 61 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
autosnap
========
ZFS snapshot automation tool

Installation
------------
- ``cd /opt``
- ``git clone https://github.com/makhomed/autosnap.git autosnap```

Configuration
-------------
- ``vim /opt/autosnap/autosnap.conf``
- write to config something like this:

.. code-block:: bash
interval hourly 24
interval daily 30
exclude tank
exclude tank/backup**
exclude tank/vm
Configuration file allow comments, from symbol ``#`` to end of line.

Configuration file has only three directives:
``interval``, ``exclude`` and ``include``.

Syntax of interval directive: ``interval <name> <count>``.
``<name>`` is name of interval, must be unique.
``<count>`` is count of snapshots to save for interval ``<name>``.

Syntax of ``include`` and ``exclude`` directives are the same:
``exclude <pattern>`` or ``include <pattern>``.

By default all datasets are included. But you can exclude some datasets
by name or by pattern. Pattern is rsync-like, ``?`` means any symbol,
``*`` means any symbol except ``/`` symbol, ``**`` means any symbol.

First match win, and if it was directive ``exclude`` - dataset will be excluded,
if it was directive ``include`` - dataset will be included.

Schedule autosnap
-----------------
- ``vim /etc/cron.d/autosnap``
- write to config something like this:

.. code-block:: bash
0 0 * * * root /opt/autosnap/autosnap daily
0 * * * * root /opt/autosnap/autosnap hourly
By default ``autosnap`` will read config from ``/opt/autosnap/autosnap.conf``.
Command line allow one switch ``-c`` to specify alternate configuration file.

One and only one command must be specified in command line. This command must
be the name of interval from configuration file.

During execution, autosnap will create one new snapshot for each included dataset
and will delete all oldest snapshots exceeding the allowed snapshots count for given interval.

183 changes: 183 additions & 0 deletions autosnap
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/python

import argparse
import datetime
import os.path
import re
import subprocess
import sys


__author__ = "Gena Makhomed"
__contact__ = "https://github.com/makhomed/autosnap"
__license__ = "GPLv3"
__version__ = "1.0.0"
__date__ = "2017-11-17"


class Config(object):

def __init__(self, configuration_file_name, command):
self.intervals = dict()
self.filters = list()
self.command = command
if not os.path.isfile(configuration_file_name):
sys.exit("configuration file '%s' not found" % configuration_file_name)
with open(configuration_file_name) as configuration_file:
lines = configuration_file.read().strip().split('\n')
for line in lines:
comment_start = line.find('#')
if comment_start > -1:
line = line[:comment_start]
line = line.strip()
if not line:
continue
line = line.replace("\t", "\x20")
name, value = line.split(None, 1)
if name == "interval":
interval_name, count_string = value.split(None, 1)
if interval_name in self.intervals:
sys.exit("bad config '%s', interval '%s' already defined" % (configuration_file_name, interval_name))
count = int(count_string)
if count < 0:
sys.exit("config interval '%s' count must be positive integer, '%d' given" % (interval_name, count))
self.intervals[interval_name] = count
elif name == "include" or name == "exclude":
self.filters.append((name == "include", self.transform_filter_line(value)))
else:
sys.exit("invalid config directive '%s'" % name)
self.filters.append((True, self.transform_filter_line("**")))
if self.command not in self.intervals:
sys.exit("bad command '%s', interval %s not defined in config" % (self.command, self.command))

def transform_filter_line(self, filter_line): # pylint: disable=no-self-use
if filter_line.find(" ") > -1:
sys.exit("config: invalid filter line '%s', spaces not allowed" % filter_line)
filter_line = filter_line.replace(r".", r"\.")
filter_line = filter_line.replace(r"?", r".")
filter_line = filter_line.replace(r"*", r"[^/]*")
filter_line = filter_line.replace(r"[^/]*[^/]*", r".*")
if filter_line[0] != '^':
filter_line = '^' + filter_line
if filter_line[-1] != '$':
filter_line = filter_line + '$'
return filter_line

def included(self, dataset):
for dataset_included, filter_line in self.filters:
if re.match(filter_line, dataset):
return dataset_included
sys.exit("config: internal error, dataset '%s' don't match any filter line")


class Process(object):

def __init__(self, *args):
self.args = args
process = subprocess.Popen(args, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, cwd='/')
self.stdout, self.stderr = process.communicate()
self.returncode = process.returncode

def failed(self):
return self.returncode != 0

def print_info(self, message):
print message + ": Process(", self.args, ") failed"
print "returncode:", self.returncode
print "stdout:", self.stdout
print "stderr:", self.stderr


class SnapMan(object):

def __init__(self, config):
self.config = config

def get_datasets(self): # pylint: disable=no-self-use
process = Process("/usr/sbin/zfs", "list", "-H", "-o", "name")
if process.failed():
print "can't read ZFS datasets"
process.print_info("fatal error")
sys.exit(1)
datasets = process.stdout.strip().split('\n')
return datasets

def create_snapshot(self, dataset):
now = datetime.datetime.now().strftime("%Y-%m-%d.%H:%M:%S")
snapshot_name = dataset + "@autosnap." + now + "." + self.config.command
process = Process("/usr/sbin/zfs", "snapshot", snapshot_name)
if process.failed():
print "can't create ZFS snapshot '%s'" % snapshot_name
process.print_info("error")

def delete_snapshot(self, snapshot_name): # pylint: disable=no-self-use
process = Process("zfs", "destroy", snapshot_name)
if process.failed():
print "can't delete ZFS snapshot '%s'" % snapshot_name
process.print_info("error")

def get_snapshots(self):
process = Process("/usr/sbin/zfs", "list", "-H", "-p", "-o", "name,creation", "-t", "snap")
if process.failed():
print "can't read ZFS snapshots"
process.print_info("fatal error")
sys.exit(1)
lines = process.stdout.strip().split('\n')
snapshots = dict()
for line in lines:
line = line.strip()
if not line:
continue
snapshot_name, creation_date_as_string = line.split()
dataset_name, snapshot_info = snapshot_name.split('@')
if not snapshot_info.startswith("autosnap."):
continue
creation_date = int(creation_date_as_string)
last_point_position = snapshot_info.rfind('.')
if last_point_position == -1:
print "unexpected snapshot name '%s'" % snapshot_name
continue
snapshot_command = snapshot_info[last_point_position + 1:]
snapshot = dict(snapshot_name=snapshot_name, dataset_name=dataset_name,
snapshot_command=snapshot_command, creation_date=creation_date)
if snapshot_command == self.config.command:
if dataset_name not in snapshots:
snapshots[dataset_name] = list()
snapshots[dataset_name].append(snapshot)
return snapshots

def delete_expired_snapshots(self, snapshots):
for dataset_name in snapshots.keys():
if self.config.included(dataset_name):
dataset_snapshots = snapshots[dataset_name]

def sort_by_creation_date(item_x, item_y):
return cmp(item_y["creation_date"], item_x["creation_date"])

dataset_snapshots.sort(sort_by_creation_date)
keep_count = self.config.intervals[self.config.command]
if len(dataset_snapshots) > keep_count:
delete_queue = dataset_snapshots[keep_count:]
for snapshot in delete_queue:
self.delete_snapshot(snapshot["snapshot_name"])

def run(self):
datasets = self.get_datasets()
for dataset in datasets:
if self.config.included(dataset):
self.create_snapshot(dataset)
snapshots = self.get_snapshots()
self.delete_expired_snapshots(snapshots)


def main():
parser = argparse.ArgumentParser(prog="autosnap")
parser.add_argument("-c", required=False, metavar="CONFIG", dest="config", default="/opt/autosnap/autosnap.conf", help="configuration file")
parser.add_argument("command", help="it must be one of config interval names")
args = parser.parse_args()
config = Config(args.config, args.command)
SnapMan(config).run()


if __name__ == "__main__":
main()
30 changes: 0 additions & 30 deletions autosnap.go

This file was deleted.

Loading

0 comments on commit 6fd9659

Please sign in to comment.