diff --git a/checkzfs.py b/checkzfs.py new file mode 100644 index 0000000..12d8cd5 --- /dev/null +++ b/checkzfs.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# vim:fenc=utf-8:noet +## Copyright 2021 sysops.tv ;-) +## BSD-2-Clause +## +## Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +## +## 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +## +## 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +## THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS +## BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +## GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +## LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +VERSION = 1.38 + +import sys +import re +import subprocess +import time +import json +import os.path +import socket +from email.message import EmailMessage + +class zfscheck(object): + ZFSLIST_REGEX = re.compile("^(?P.*?)(?:|@(?P.*?))\t(?P\d+)\t(?P\d+)\t(?P\d+)\t(?P\d+)$",re.M) ## todo used/referenced ... diff zum replica + ZFS_LOCAL_SNAPSHOTS = [] + ZFS_DATASTORES = {} + ZFS_RESULT_SNAPSHOT = {} + VALIDCOLUMNS = ["dataset","snapshot","creation","guid","used","referenced","size","age","status","copy"] ## valid columns + DEFAULT_COLUMNS = ["dataset","snapshot","age","count","copy"] ## default columns + DATEFORMAT = "%a %d.%b.%Y %H:%M" + COLOR_CONSOLE = { + "warn" : "\033[93m", + "crit" : "\033[91m", + "reset" : "\033[0m" + } + COLUMN_NAMES = { ## Namen frei editierbar + "dataset" : "Dataset", + "snapshot" : "Snapshotname", + "creation" : "Erstellungszeit", + "age" : "Alter", + "count" : "Anzahl", + "used" : "Größe", + "copy" : "Replikat" + } + COLUMN_ALIGN = { + "dataset" : "<", + "snapshot" : "<", + "copy" : "<", + "status" : "^" + } + + TIME_MULTIPLICATOR = { ## todo + "h" : 60, ## Stunden + "d" : 60*24, ## Tage + "w" : 60 * 24 * 7, ## Wochen + "m" : 60 * 24 * 30 ## Monat + } + COLUMN_MAPPER = {} + + def __init__(self,**kwargs): + _start_time = time.time() + self.remote = None + self.filter = None + self.rawdata = False + self.sortreverse = False + self._check_kwargs(kwargs) + _data = self.get_data() + _script_runtime = time.time() - _start_time + if self.output == "text": + print(self.table_output(_data)) + if self.output == "html": + print( self.html_output(_data) ) + if self.output == "mail": + self.mail_output(_data) + if self.output == "checkmk": + self.checkmk_output(_data) + if self.output == "json": + print(self.json_output(_data)) + + print ("Runtime: {0:.2f}".format(_script_runtime)) + + def _check_kwargs(self,kwargs): + ## argumente überprüfen + for _k,_v in kwargs.items(): + if _k == "columns": + _default = self.DEFAULT_COLUMNS[:] + + if not _v: + self.columns = _default + continue ## defaults + # add modus wenn mit + + if not _v.startswith("+"): + _default = [] + else: + _v = _v[1:] + _v = _v.split(",") + for _column in _v: + if _column not in self.VALIDCOLUMNS: + raise Exception("invalid column {0} ({1})".format(_v,",".join(self.VALIDCOLUMNS))) + _default.append(_column) + _v = list(_default) + + + if _k == "sort" and _v: + ## sortierung desc wenn mit + + if _v.startswith("+"): + self.sortreverse = True + _v = _v[1:] + if _v not in self.VALIDCOLUMNS: + raise Exception("invalid sort column: {0} ({1})".format(_v,",".join(self.VALIDCOLUMNS))) + + if _k == "threshold" and _v: + _v = _v.split(",") + ## todo tage etc + _v = list(map(int,_v[:2])) ## convert zu int + if len(_v) == 1: + _v = (float("inf"),_v[0]) + + if _k == "filter" and _v: + _v = re.compile(_v) + + setattr(self,_k,_v) + + ## funktionen zum anzeigen / muss hier da sonst kein self + if not self.rawdata: + self.COLUMN_MAPPER = { + "creation" : self.convert_ts_date, + "age" : self.seconds2timespan, + "used" : self.format_bytes, + "size" : self.format_bytes, + "referenced" : self.format_bytes, + } + + def get_data(self): + _data = self._call_proc() + self.get_local_snapshots(_data) + if self.remote: + _data = self._call_proc(self.remote) + + return self.get_snapshot_results(_data) + + def get_local_snapshots(self,data): + for _entry in self._parse(data): + _entry.update({ + "creation" : int(_entry.get("creation",0)) + }) + if _entry.get("snapshot") == None: + self.ZFS_DATASTORES[_entry.get("dataset")] = _entry + self.ZFS_LOCAL_SNAPSHOTS.append(_entry) + + def get_snapshot_results(self,data): + _now = time.time() + for _entry in self._parse(data): + if _entry.get("snapshot") == None: + continue ## TODO + if self.filter and not self.filter.search("{dataset}@{snapshot}".format(**_entry)): + continue + _timestamp = int(_entry.get("creation",0)) + _dataset = _entry["dataset"] + _entry.update({ + "creation" : _timestamp, + "age" : int(_now - _timestamp), + "count" : 1, + "size" : self.ZFS_DATASTORES.get(_dataset,{}).get("used",0), + "used" : int(_entry.get("used",0)), + "referenced": int(_entry.get("referenced",0)), + "copy" : "", + "status" : self.check_threshold(_now -_timestamp) + }) + _copys = list( + filter(lambda x: x.get("guid") == _entry.get("guid") and x.get("dataset") != _entry.get("dataset") , self.ZFS_LOCAL_SNAPSHOTS) + ) + if len(_copys) > 0: + _entry["copy"] = ",".join(["{0}".format(_x.get("dataset")) for _x in _copys]) + else: + if self.backup: + continue + _exist_entry = self.ZFS_RESULT_SNAPSHOT.get(_dataset) + if _exist_entry: + _entry["count"] += _exist_entry.get("count") ## update counter + if _exist_entry.get("creation") <= _entry.get("creation"): ## newer + _exist_entry.update(_entry) + else: + _exist_entry["count"] = _entry["count"] + else: + self.ZFS_RESULT_SNAPSHOT[_dataset] = _entry + + + return list(self.ZFS_RESULT_SNAPSHOT.values()) + + def _parse(self,data): + _ret = [] ## Fixme + for _match in self.ZFSLIST_REGEX.finditer(data): + #yield _match.groupdict() + _ret.append(_match.groupdict()) + return _ret + + def _call_proc(self,remote=None): + zfs_args = ["zfs", "list", + "-t", "all", ## list snapshots / TODO:all + "-Hp", ## script und numeric output + "-o", "name,creation,guid,used,referenced", ## attributes to show + "-r" ## recursive + ] + if remote: + _privkeyoption = [] + if self.ssh_identity: + _privkeyoption = ["-i",self.ssh_identity] + _sshoptions = "BatchMode yes" + _parts = remote.split(":") + _port = "22" ## default port + if len(_parts) > 1: + remote = _parts[0] + _port = _parts[1] + zfs_args = ["ssh", + remote, ## Hostname + "-p", _port, + "-o", _sshoptions, ## ssh options + ] + _privkeyoption + zfs_args + _proc = subprocess.Popen(zfs_args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,shell=False) + _stdout, _stderr = _proc.communicate() + if _proc.returncode > 0: + raise Exception(_stderr.decode(sys.stdout.encoding)) ## Raise Errorlevel with Error from proc + return _stdout.decode(sys.stdout.encoding) + + def convert_ts_date(self,ts): + return time.strftime(self.DATEFORMAT,time.localtime(ts)) + + def check_threshold(self,age): + if not self.threshold: + return "ok" + age /= 60 ## default in minuten + #print("Age: {0} - {1} - {2}".format(age,list(zip(self.threshold,("warn","crit"))),list(filter(lambda y: y[0] < age,zip(self.threshold,("warn","crit")))))) + _status = list( + map(lambda x: x[1], ## return only last + filter(lambda y: y[0] < age, ## check threshold Texte + zip(self.threshold,("warn","crit")) + ) + ) + ) + if not _status: + _status = ["ok"] + return _status[-1] + + + @staticmethod + def format_bytes(size,unit='B'): + # 2**10 = 1024 + size = float(size) + if size == 0: + return "0" + power = 2**10 + n = 0 + power_labels = {0 : '', 1: 'K', 2: 'M', 3: 'G', 4: 'T'} + while size > power: + size /= power + n += 1 + return "{0:.2f} {1}{2}".format(size, power_labels[n],unit) + + @staticmethod + def seconds2timespan(seconds,details=2,seperator=" ",template="{0:.0f}{1}",fixedview=False): + _periods = ( + ('W', 604800), + ('T', 86400), + ('Std', 3600), + ('Min', 60), + ('Sek', 1) + ) + _ret = [] + for _name, _period in _periods: + _val = seconds//_period + if _val: + seconds -= _val * _period + #if _val == 1: + # _name = _name[:-1] + _ret.append(template.format(_val,_name)) + else: + if fixedview: + _ret.append("") + return seperator.join(_ret[:details]) + + def _datasort(self,data): + if not self.sort: + return data + return sorted(data, key=lambda k: k[self.sort],reverse=self.sortreverse) + + def checkmk_output(self,data): + if not data: + return + print ("<<>>") + for _item in data: + print("<<{0}>>".format(_item.get("dataset","").replace(" ","_"))) + for _info,_val in _item.items(): + print("{0}: {1}".format(_info,_val)) + + def table_output(self,data): + if not data: + return + _header = data[0].keys() if not self.columns else self.columns + _header_names = [self.COLUMN_NAMES.get(i,i) for i in _header] + _converter = dict((i,self.COLUMN_MAPPER.get(i,(lambda x: str(x)))) for i in _header) + + _output_data = [_header_names] + _line_status = [] + for _item in self._datasort(data): + _line_status.append(_item.get("status")) + _output_data.append([_converter.get(_col)(_item.get(_col,"")) for _col in _header]) + + _maxwidth = [max(map(len,_col)) for _col in zip(*_output_data)] ## max column breite + _format = " ║ ".join(["{{:{}{}}}".format(self.COLUMN_ALIGN.get(_h,">"),_w) for _h,_w in zip(_header,_maxwidth)]) ## format bilden + _line_print = False + _out = [] + _status = "ok" + for _item in _output_data: + if _line_print: + _status = _line_status.pop(0) + if _status != "ok": + _out.append(self.COLOR_CONSOLE.get(_status,"") + _format.format(*_item) + self.COLOR_CONSOLE.get("reset")) + else: + _out.append(_format.format(*_item)) + if not _line_print: + _out.append("═╬═".join(map(lambda x: x*"═",_maxwidth))) ## trennlinie + _line_print = True + return "\n".join(_out) + + def html_output(self,data): + if not data: + return + _header = data[0].keys() if not self.columns else self.columns + _header_names = [self.COLUMN_NAMES.get(i,i) for i in _header] + _converter = dict((i,self.COLUMN_MAPPER.get(i,(lambda x: str(x)))) for i in _header) + + _out = [] + _out.append("") + _out.append("") + _out.append("ZFS") + _out.append("") + _out.append("".format("".format("
{0}
".join(_header_names))) + for _item in self._datasort(data): + _out.append("
{0}
".join([_converter.get(_col)(_item.get(_col,"")) for _col in _header]),_item.get("status","ok"))) + _out.append("
") + return "".join(_out) + + def mail_output(self,data): + _msg = EmailMessage() + self.hostname = socket.getfqdn() + _msg.set_content(self.checkmk_output) + _msg.add_alternative(self.html_output(data),subtype="html") + _msg["From"] = "ZFS-Checkscript on {0}