#!/usr/bin/env python3
# Shebang needed this time to find the interpreter!

import requests
import argparse
import json
import time
import os
import re
import hashlib
import uuid
import random

from pathlib import Path
NO_CMK_STORAGE = False
NO_ZABBIX = False

# Make sure this script can be run from Checkmk 2.3.0 onwards and as active check without Checkmk APIs
# The new password store API is available starting with 2.5.0
try:
    from cmk.password_store.v1_unstable import (
        Secret as CmkSecret,
        parser_add_secret_option as cmk_parser_add_secret_option,
        resolve_secret_option,
        dereference_secret,
    )
except ModuleNotFoundError:
    # Try to use the old one if importing the new one fails...
    try:
        from cmk.utils import password_store as cmk_legacy_pw_store
    except ModuleNotFoundError:
        # Silently ignore, passwords must be provided plain text!
        True
    
# The new Checkmk storage API is available starting with 2.5.0
try:
    from cmk.server_side_programs.v1_unstable import Storage as CmkStorage
except ModuleNotFoundError:
    # Silently ignore and fall back to temporary storage
    NO_CMK_STORAGE = True
    
# When using with Zabbix, use their sender with multiple values per call:
try:
    from zabbix_utils import ItemValue as ZabbixItemValue
    from zabbix_utils import Sender as ZabbixSender
except ModuleNotFoundError:
    # Silently ignore
    NO_ZABBIX = True


class Storage:
    def __init__(self, config):
        self.config = config
        self.stor = None
        self.is_dir = True
        self.cachedir = None
        self.cmkstorage = None
        try:
            self.cmkstorage = CmkStorage('patchmon', config['storkey'])
            self.is_dir = False
            if config['debug']: print(f"Managed to init CmkStorage with key {config['storkey']}")
        except NameError:
            if 'OMD_ROOT' in os.environ:
                self.cachedir = os.environ['OMD_ROOT'] + "/tmp/patchmon/" + config['storkey']
                if config['debug']: print(f"Initialized file storage at {self.cachedir} (using OMD_ROOT)")
                if not os.path.exists(self.cachedir): os.makedirs(self.cachedir)
            else:
                try:
                    self.cachedir = config['cache'] + "/" + config['storkey']
                    if config['debug']: print(f"Initialized file storage at {self.cachedir} (without OMD_ROOT)")
                    if not os.path.exists(self.cachedir): os.makedirs(self.cachedir)
                except TypeError:
                    True
            
    def get_hostlist(self):
        hostlist = None
        outdated = True
        if self.cachedir is None:
            return None, True
        if self.is_dir:
            listfile = self.cachedir + "/hostlist.json"
            if self.config['debug']: print(f"Searching for hostlist: {listfile}")
            if os.path.exists(listfile):
                with open(listfile, "r") as file:
                    c = file.read()
                    try:
                        hostlist = json.loads(c)
                    except json.decoder.JSONDecodeError:
                        if self.config['debug']: print(f"Invalid JSON for hostlist: {listfile}")
                        hostlist = None
                if time.time() - os.path.getmtime(listfile) < self.config['listage']:
                    outdated = False
        else:
            jstr = self.cmkstorage.read("hostlist", None)
            if jstr:
                hostlist = json.loads(jstr)
                if 'mtime' in hostlist:
                    if time.time() - hostlist['mtime'] < self.config['listage']:
                        outdated = False
        return hostlist, outdated
        
    def set_hostlist(self, hostlist):
        if self.is_dir:
            listfile = self.cachedir + "/hostlist.json"
            if self.config['debug']: print(f"Writing to hostlist: {listfile}")
            with open(listfile, "w") as file:
                file.write(json.dumps(hostlist))
        else:
            hostlist['mtime'] = time.time()
            self.cmkstorage.unset("hostlist")
            self.cmkstorage.write("hostlist", json.dumps(hostlist))
        
    def get_host(self, hostid):
        hostdata = None
        outdated = True
        if parse_uuid(hostid):
            if self.is_dir:
                hostfile = self.cachedir + "/" + hostid + ".json"
                if os.path.exists(hostfile):
                    with open(hostfile, "r") as file:
                        c = file.read()
                        try:
                            hostdata = json.loads(c)
                        except json.decoder.JSONDecodeError:
                            if self.config['debug']: print(f"Invalid JSON for hostdata: {hostfile}")
                            hostdata = None
                    if time.time() - os.path.getmtime(hostfile) < self.config['hostage']:
                        outdated = False
            else:
                jstr = self.cmkstorage.read(hostid, None)
                if jstr:
                    hostdata = json.loads(jstr)
                    if 'mtime' in hostdata:
                        if time.time() - hostdata['mtime'] < self.config['hostage']:
                            outdated = False
        return hostdata, outdated

    def set_host(self, hostid, hostdata):
        if parse_uuid(hostid):
            hostdata['mtime'] = time.time()
            if self.is_dir:
                hostfile = self.cachedir + "/" + hostid + ".json"
                if self.config['debug']: print(f"Writing to hostfile: {hostfile}")
                with open(hostfile, "w") as file:
                    file.write(json.dumps(hostdata))
            else:
                self.cmkstorage.unset(hostid)
                self.cmkstorage.write(hostid, json.dumps(hostdata))


class SingleHost:
    def __init__(self, config, storage, hostid, hostname, session):
        self.config = config
        self.storage = storage
        self.hostid = hostid
        self.hostname = hostname
        self.hosturl = None
        self.hostdata = None
        self.session = session
        self.populate()
    
    def populate(self):
        outdated = True
        writeback = False
        self.hostdata, outdated = storage.get_host(self.hostid)
        oldpackages = {}
        if outdated == True or self.hostdata is None:
            if self.config['debug']: print(f"Host data for {self.hostid} is missing or outdated.")
            if self.hostdata is not None and 'packages' in self.hostdata: oldpackages = self.hostdata['packages']
            if time.time() - START_TIME < self.config['maxexec']:
                if self.config['debug']: print(f"Calling PatchMon server for {self.hostid}...")
                self.hostdata = self.retrieve_hostdata()
                self.hosturl = self.config["url"]["host"].format(baseurl=self.config["baseurl"].rstrip('/'), uuid=self.hostdata['host_id'])
                self.hostdata["url"] = self.hosturl
            else:
                if self.config['debug']: print(f"Maxexec reached, using cached data for {self.hostid}...")
                return
            writeback = True
        if self.config['reboot']:
            if not 'needs_reboot' in self.hostdata:
                if self.config['debug']: print(f"Reboot info for {self.hostid} is missing.")
                x = self.retrieve_hostextra()
                self.hostdata['needs_reboot'] = x['needs_reboot']
                self.hostdata['reboot_reason'] = x['reboot_reason']
                writeback = True
        if self.config['grace']:
            if not 'packages' in self.hostdata:
                if self.config['debug']: print(f"Package info for {self.hostid} is missing.")
                self.hostdata['packages'] = self.retrieve_pkglist(oldpackages)
                writeback = True
        # Put this quite last:
        if writeback == True:
            self.storage.set_host(self.hostid, self.hostdata)
        
    def retrieve_hostdata(self):
        error = 'Error retrieving host statistics. Since this happened after retrieving '\
            'the host list, this is most likely caused by an outdated version of PatchMon. '
        basic = requests.auth.HTTPBasicAuth(self.config['pmuser'], self.config['pmpass'])
        url = self.config['url']['stats'].format(baseurl=self.config['baseurl'], uuid=self.hostid)
        try:
            if self.session is None:
                self.session = requests.Session()
            response = self.session.get(url, auth=basic)
        except Exception as err:
            gently_exit(error + str(err), self.config)
        if response.status_code > 200:
            gently_exit(error + "Got status code: " + str(response.status_code), self.config)
        j = response.json()
        # print(j)
        return j
        
    def retrieve_hostextra(self):
        error = 'Error retrieving host details. Since this happened after retrieving '\
            'the host list, this is most likely caused by an outdated version of PatchMon. '
        basic = requests.auth.HTTPBasicAuth(self.config['pmuser'], self.config['pmpass'])
        extraurl = self.config['url']['extra'].format(baseurl=self.config['baseurl'], uuid=self.hostid)
        try:
            if self.session is None:
                self.session = requests.Session()
            response = self.session.get(extraurl, auth=basic)
        except Exception as err:
            gently_exit(error + str(err), self.config)
        if response.status_code > 200:
            gently_exit(error + "Got status code: " + str(response.status_code), self.config)
        j = response.json()
        # print(j)
        return j
    
    def retrieve_pkglist(self, oldpackages):
        error = 'Error retrieving package list. Since this happened after retrieving '\
            'the host list, this is most likely caused by an outdated version of PatchMon. '
        basic = requests.auth.HTTPBasicAuth(self.config['pmuser'], self.config['pmpass'])
        pkglisturl = self.config['url']['pkglist'].format(baseurl=self.config['baseurl'], uuid=self.hostid)
        try:
            if self.session is None:
                self.session = requests.Session()
            response = self.session.get(pkglisturl, auth=basic, params={"updates_only": "true"})
        except Exception as err:
            gently_exit(error + str(err), self.config)
        if response.status_code > 200:
            gently_exit(error + "Got status code: " + str(response.status_code), self.config)
        j = response.json()["packages"]
        plist = self.compare_pkglist(j, oldpackages)
        # print(plist)
        return plist
        
    def compare_pkglist(self, packages, oldpackages):
        t_now = time.time()
        plist = {}
        for p in packages:
            if p["name"] in oldpackages:
                pmeta = oldpackages[p["name"]]
                if oldpackages[p["name"]]["is_security_update"] is True:
                    pmeta["is_security_update"] = True
            else:
                pmeta = { 
                    "is_security_update": p["is_security_update"],
                    "first_seen" : t_now,
                }
            plist[p["name"]] = pmeta
        return plist
        
    def generate_monitoring_plugins_output(self):
        if self.config['only_reboot'] == True:
            self.generate_monitoring_plugins_output_reboot()
        else:
            self.generate_monitoring_plugins_output_patches()
            
    def generate_monitoring_plugins_output_reboot(self):
        t_end = time.time()
        duration = t_end - START_TIME
        host_data = self.hostdata
        conf = self.config
        if not 'needs_reboot' in host_data or host_data is None:
            output = f"query failed | duration={duration:.3f}s;;;;\nThe needs_reboot flag in the cached data is missing. This is most likely due to two checks (one for patches and one for reboots being run on a single host. Add the CLI flag --reboot also to the check for patches to query and cache the needs_reboot flog."
            print(output)
            exit(3)
        if host_data['needs_reboot'] > 0:
            reboot_reason = host_data['reboot_reason'].replace('|', '')
            output = f"reboot required | duration={duration:.3f}s;;;;\nReason given: {reboot_reason}"
            print(output)
            exit(conf['states'][2])
        else:
            output = f"no reboot required | duration={duration:.3f}s;;;;"
            print(output)
            exit(0)

    def generate_monitoring_plugins_output_patches(self):
        t_end = time.time()
        duration = t_end - START_TIME
        host_data = self.hostdata
        conf = self.config
        if host_data['security_updates'] > 0:
            output = f"security updates missing | security_updates={host_data['security_updates']};;;; total_updates={host_data['outdated_packages']};;;; duration={duration:.3f}s;;;;\n{host_data['security_updates']} security updates are missing, in total {host_data['outdated_packages']} updates are missing."
            print(output)
            exit(conf['states'][0])
        elif host_data['outdated_packages'] > 0:
            output = f"updates missing | security_updates={host_data['security_updates']};;;; total_updates={host_data['outdated_packages']};;;; duration={duration:.3f}s;;;;\n{host_data['outdated_packages']} updates are missing, none of those are security updates."
            print(output)
            exit(conf['states'][1])
        else:
            output = f"no updates missing | security_updates={host_data['security_updates']};;;; total_updates={host_data['outdated_packages']};;;; duration={duration:.3f}s;;;;"
            print(output)
            exit(0)
            
    def zabbix_send_traps(self):
        if self.hostdata is None:
            print(f"ERROR: No data for {self.hostname}")
            return
        now = time.time()
        conf = self.config
        print(f"Trying to set Zabbix items for {self.hostname}")
        sender = ZabbixSender(server=conf['zabbix'], port=conf['zabbix_port'])
        res = sender.send_value(
            self.hostname,
            'patchmon.updates',
            self.hostdata['outdated_packages'],
            now
        )
        if res.failed > 0:
            print(f"Failed setting patchmon.updates for {self.hostname}")
        res = sender.send_value(
            self.hostname,
            'patchmon.security_updates',
            self.hostdata['security_updates'],
            now
        )
        if res.failed > 0:
            print(f"Failed setting patchmon.security_updates for {self.hostname}")
        if "needs_reboot" in self.hostdata:
            res = sender.send_value(
                self.hostname, 
                'patchmon.reboot',
                self.hostdata['needs_reboot'],
                now
            )
            if res.failed > 0:
                print(f"Failed setting patchmon.reboot for {self.hostname}")
    
    def generate_checkmk_output(self):
        print('<<<patchmon_patches>>>')
        if self.hostdata is None:
            print('{ "error": "No data" }')
            return
        print(json.dumps(self.hostdata))
        print('<<<labels:sep(0)>>>')
        print('{"patchmon/monitored":"true"}')


class AllHosts:
    def __init__(self, config, storage):
        self.hostlist = None
        self.outdated = None
        self.hostdata = {}
        self.storage = storage
        self.config = config
        self.session = None
        self.get_hostlist()
        if self.config["single_host"] is not None:
            self.narrow_down_hostlist()
        self.get_all_hostdata()
    
    def get_hostlist(self):
        hostlist, outdated = self.storage.get_hostlist()
        if outdated == True or hostlist is None:
            url = self.config['url']['hosts']
            if self.config['debug']: print(f"Hostlist outdated or not existing, query from: {url}")
            basic = requests.auth.HTTPBasicAuth(self.config['pmuser'], self.config['pmpass'])
            try:
                if self.session is None:
                    self.session = requests.Session()
                response = self.session.get(url, auth=basic)
            except Exception as err:
                gently_exit(str(err), self.config)
            if response.status_code > 200:
                gently_exit("Error retrieving host list. Got status code: " + str(response.status_code) + ". When on Checkmk lower than 2.5.0, please use the password store!", self.config)
            hostlist = response.json()
            outdated = False
            self.storage.set_hostlist(hostlist)
        self.hostlist = hostlist
        self.outdated = outdated
        
    def narrow_down_hostlist(self):
        tmp_hostlist = { "hosts" : [ ] }
        for host in self.hostlist["hosts"]:
            if self.config['name'] == "id" and host["id"] == self.config['single_host']:
                tmp_hostlist["hosts"].append(host)
            elif self.config['name'] == "hostname" and host["hostname"] == self.config['single_host']:
                tmp_hostlist["hosts"].append(host)
            elif host["friendly_name"] == self.config['single_host']:
                tmp_hostlist["hosts"].append(host)
        self.hostlist = tmp_hostlist
        if self.config['debug']: print(f"Host list is now {self.hostlist}")
    
    def get_all_hostdata(self):
        for host in self.hostlist["hosts"]:
            self.hostdata[host["id"]] = SingleHost(self.config, self.storage, host["id"], host[self.config["name"]], self.session)
            if self.hostdata[host["id"]].session is not None:
                if self.session is None:
                    self.session = self.hostdata[host["id"]].session
            
    def generate_output(self):
        if self.config['no_output'] == True:
            return
        elif self.config['monitoring_plugin'] == True:
            self.generate_monitoring_plugins_output()
        elif self.config['zabbix'] is not None:
            for host in self.hostlist["hosts"]:
                self.hostdata[host["id"]].zabbix_send_traps()
        else:
            self.generate_checkmk_output()
            
    def generate_monitoring_plugins_output(self):
        for host in self.hostlist["hosts"]:
            self.hostdata[host["id"]].generate_monitoring_plugins_output()
        if len(self.hostlist["hosts"]) < 1:
            duration = time.time() - START_TIME
            output = f"unable to determine, data missing | duration={duration:.3f}s;;;;"
            print(output)
            exit(3)
            
    def generate_checkmk_output(self):
        for h in self.hostlist['hosts']:
            if self.config['single_host'] is None: print('<<<<' + self.hostdata[h["id"]].hostname + '>>>>')
            self.hostdata[h["id"]].generate_checkmk_output()
            if self.config['single_host'] is None: print('<<<<>>>>')
        t_end = time.time()
        agentstats = {
            "hoststotal": len(self.hostlist['hosts']),
            "hostsskipped": 0,
            "duration": t_end - START_TIME
        }
        print('<<<patchmon_server>>>')
        print(json.dumps(agentstats))


class AgentConfig:
    def __init__(self):
        self.config = {
            'maxexec': 30,
            'listage': 1800,
            'hostage': 1800,
            'name': 'friendly_name',
            'states': [ 2, 1, 2 ],
            'baseurl': None,
            'secret': None,
            'secret_id': None,
            'baseurl': None,
            'cache': None,
            'single_host': None,
            'monitoring_plugin': False,
            'zabbix': None,
            'zabbix_port': 10051,
            'reboot': False,
            'grace': False,
            'no_output': False,
            'debug': False,
            'no_output': False,
            'only_reboot': False,
            'url' : {
                "api": "{baseurl}/api/v1/api/hosts",
                "host": "{baseurl}/hosts/{uuid}",
                "hosts": None,
                "stats": "{baseurl}/api/v1/api/hosts/{uuid}/stats",
                "extra": "{baseurl}/api/v1/api/hosts/{uuid}/system",
                "pkglist": "{baseurl}/api/v1/api/hosts/{uuid}/packages"
            },
        }
        self.config = self.create_config(self.config)
        self.check_config()
        
    def get_cli_arguments(self):
        parser = argparse.ArgumentParser("agent_patchmon")
        parser.add_argument(
            "--baseurl",
            help="Specify the baseurl for your PatchMon installation.",
        )
        parser.add_argument(
            "--name",
            help="Chose whether 'id', 'hostname' or 'friendly_name' should be used to create hostname. Default is 'friendly_name'.",
        )
        parser.add_argument(
            # Only makes sense when used as Checkmk special agent with piggyback mode 
            "--maxexec",
            help="State a maximum time the special agent should take to retrieve patch details. Default is 30s. Irrelevant when querying a single host.",
        )
        parser.add_argument(
            "--list",
            help='State the interval (in seconds) host lists should be cached before retrieving them. Default is 1800s, +/-10 percent are randomly applied to avoid conflicts from parallel calls.',
        )
        parser.add_argument(
            "--check",
            help='State the interval (in seconds) patch info for a single hosts should stay cached before retrieving. Default is 1800s, +/-10 percent are randomly applied to avoid conflicts from parallel calls.',
        )
        parser.add_argument(
            "--reboot",
            help="If present, also query whether a reboot is required and ask for the reasons.",
            action="store_true",
        )
        parser.add_argument(
            "--grace",
            help="Get the package list for being able to create a grace period in the check plugin.",
            action="store_true",
        )
        parser.add_argument(
            "--monitoring-plugin",
            help="Behave as monitoring plug-in for Nagios and related systems like Icinga.",
            action="store_true",
        ),
        parser.add_argument(
            "--zabbix",
            help="Act as a sender for the Zabbix trapper protocol. Takes the IP address of the Zabbix server as argument. This will try to set the items patchmon.updates, patchmon.security_updates and patchmon.reboot for each host.",
        ),
        parser.add_argument(
            "--port",
            help="Use together with --zabbix to specify the trapper port. If no port is specified, the default (10051) is used.",
        ),
        parser.add_argument(
            "--only-reboot",
            help="Only check whether reboot is required. Only effective in monitoring plug-in mode.",
            action="store_true",
        ),
        parser.add_argument(
            "--single-host",
            help="Query only data for one host, this is necessary for using as monitoring-plugins.org compatible operation. If used in the special agent mode (Checkmk only), the output will not be wrapped in piggyback sections.",
        ),
        parser.add_argument(
            "--cache",
            help="Specify the directory to cache retrieved data.",
        ),
        parser.add_argument(
            "--states",
            help="Specify a comma separated list of the exit codes for: missing security updates, missing regular updates and a required reboot. The default is: 2,1,2.",
        ),
        parser.add_argument(
            "--debug",
            help="Print debug output. This might disturb what your monitoring system expects (except Zabbix where data is written to the trapper). So use only for testing and initializing the cache.",
            action="store_true",
        ),
        parser.add_argument(
            "--no-output",
            help="Print no monitoring system related output. Use together with '--debug' to test the execution and initialyze the cache.",
            action="store_true",
        ),
        try:
            # Try the 2.5.0 helper:
            cmk_parser_add_secret_option(
                parser,
                short="-s",
                long="--secret",
                help="Specify the id:token to log in to PatchMon.",
                required=True
            )
        except NameError:
            # Fall back to manually adding:
            parser.add_argument(
                "--secret-id",
                help="Reference the ID of the secret as stored in the password store. Requires being run from Checkmk 2.3.0 or higher.",
            )
            # Fall back to manually adding:
            parser.add_argument(
                "--secret",
                help="Specify the id:token to log in to PatchMon.",
            )
        return parser.parse_args()
    
    
    def get_access_token(self, args):
        userpass = None
        try:
            # The 2.5 method first: Checkmk cares for differentiating between plain password and store reference
            userpass = resolve_secret_option(args, "secret").reveal().split(":")
            return userpass
        except:
            # We have Checkmk 2.4 or 2.3 where the password store is an internal API
            # Here we have to differentiate between the two possible parameters
            if args.secret_id:
                # store_reference, store_path = args.secret_id.split(":")
                store_reference = args.secret_id.split(":")[0]
                try:
                    userpass = cmk_legacy_pw_store.lookup(cmk_legacy_pw_store.password_store_path(), store_reference).split(":")
                    # userpass = cmk_legacy_pw_store.lookup(store_path, store_reference).split(":")
                    return userpass
                except:
                    # Either the lookup failed (Checkmk 2.3 and explicit password) or the legacy_pw_store is not available.
                    # In such cases, assume that a plain password was passed
                    userpass = args.secret_id.split(":")
                    return userpass
            else:
                try:
                    userpass = args.secret.split(":")
                except AttributeError:
                    return [ None, None ]
                return userpass
    
    def create_config(self, cfg):
        config = cfg
        args = self.get_cli_arguments()
        # Create config for regular arguments
        if args.monitoring_plugin: config['monitoring_plugin'] = True
        if args.maxexec: config['maxexec'] = int(float(args.maxexec))
        if args.list: config['listage'] = int(float(args.list))
        if args.check: config['hostage'] = int(float(args.check))
        if args.baseurl: config['baseurl'] = args.baseurl
        if args.secret: config['secret'] = args.secret
        if args.secret_id: config['secret_id'] = args.secret_id
        if args.baseurl: config['baseurl'] = args.baseurl
        if args.cache: config['cache'] = args.cache
        if args.single_host: config['single_host'] = args.single_host
        if args.zabbix: config['zabbix'] = args.zabbix
        if args.debug: config['debug'] = args.debug
        if args.no_output: config['no_output'] = args.no_output
        if args.port: config['port'] = 10051
        if args.reboot: config['reboot'] = args.reboot
        if args.only_reboot: config['only_reboot'] = args.only_reboot
        if args.grace: config['grace'] = args.grace
        if args.single_host: config['single_host'] = args.single_host
        if args.states:
            n = 0
            for s in args.states.split(','):
                config['states'][n] = int(s)
                n += 1
        # Now retrieve username and password:
        userpass = self.get_access_token(args)
        config['pmuser'] = userpass[0]
        config['pmpass'] = userpass[1]
        # Derive the key for the storage from the user name
        h = hashlib.sha256()
        h.update(str(userpass[0]).encode("utf-8"))
        config['storkey'] = h.hexdigest()
        config["url"]["hosts"] = config["url"]["api"].format(baseurl=args.baseurl)
        for k in [ 'listage', 'hostage' ]:
            rnd = random.random()
            fac = 0.9 + rnd / 5.0
            config[k] = int(config[k] * fac)
        return config
        
    def check_config(self):
        if self.config["monitoring_plugin"] == True:
            self.check_config_monitoring_plugin()
        elif self.config["zabbix"] is not None:
            self.check_config_zabbix()
        else:
            self.check_config_general()
            
    def check_config_monitoring_plugin(self):
        t_end = time.time()
        config = self.config
        duration = t_end - START_TIME
        if config['baseurl'] is None:
            output = f"query failed | duration={duration:.3f}s;;;;\nSpecify the base URL for your patchmon server including the protocol ('http://' or 'https://'), but without the trailing slash with the CLI option --baseurl."
            print(output)
            exit(3)
        if (config['secret'] is None and config['secret_id'] is None):
            output = f"query failed | duration={duration:.3f}s;;;;\nSpecify colon separated user:pass pair with the CLI option --secret."
            print(output)
            exit(3)
        if config['cache'] is None:
            output = f"query failed | duration={duration:.3f}s;;;;\nSpecify a cache directory to store the host list and the patch states with the CLI option --cache."
            print(output)
            exit(3)
        if config['single_host'] is None:
            output = f"query failed | duration={duration:.3f}s;;;;\nSpecify the host for which to query for updates with the CLI option --single-host."
            print(output)
            exit(3)
    
    def check_config_zabbix(self):
        if NO_ZABBIX == True:
            output = f"ERROR: The zabbix_utils package could not be imported. Use 'python3 -m pip install zabbix_utils' to install in your venv."
            print(output)
            exit(0)
        if self.config['baseurl'] is None:
            output = f"ERROR: Specify the base URL for your patchmon server including the protocol ('http://' or 'https://'), but without the trailing slash with the CLI option --baseurl."
            print(output)
            exit(0)
        if (self.config['secret'] is None and self.config['secret_id'] is None):
            output = f"ERROR: Specify colon separated user:pass pair with the CLI option --secret."
            print(output)
            exit(0)
        if self.config['cache'] is None:
            output = f"ERROR: Specify a cache directory to store the host list and the patch states with the CLI option --cache."
            print(output)
            exit(0)
    
    def check_config_general(self):
        error = ""
        ec = 0
        if self.config['baseurl'] is None:
            error = error + " Specify the base URL for your patchmon server including the protocol ('http://' or 'https://'), but without the trailing slash with the CLI option --baseurl."
            ec += 1
        if (self.config['secret'] is None and self.config['secret_id'] is None):
            error = error + " Specify colon separated user:pass pair with the CLI option --secret or pass an option to the password store using --secret-id."
            ec += 1
        if NO_CMK_STORAGE == True and not 'OMD_ROOT' in os.environ and self.config["cache"] is None:
            error = error + " Specify a cache directory to store the host list and the patch states with the CLI option --cache. Or make sure the Storage API (Checkmk 2.5+) is accessible. Or make sure the OMD_ROOT environment variable is set."
            ec += 1
        if ec > 0:
            agentstats = { "error": error.strip() }
            print('<<<patchmon_server>>>')
            print(json.dumps(agentstats))
            exit(0)


def parse_uuid(raw: str) -> uuid.UUID | None:
    try:
        return uuid.UUID(raw)
    except ValueError:
        return None


def gently_exit(error_msg, conf):
    duration = time.time() - START_TIME
    if conf['monitoring_plugin'] == True:
        output = f"ERROR | duration={duration:.3f}s;;;;\n{error_msg}"
        print(output)
        exit(3)
    elif conf['zabbix'] is not None:
        print("ERROR: {error_msg}, took {duration:.3f}s, no data written to trap.")
        exit(0)
    else:
        agentstats = { "error": error_msg }
        print('<<<patchmon_server>>>')
        print(json.dumps(agentstats))
        exit(0)


START_TIME = time.time()
agconf = AgentConfig()
conf = agconf.config
storage = Storage(conf)

hostlist = AllHosts(conf, storage)
hostlist.generate_output()
