#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MirOS OR LGPL-2.0-or-later
#
# Copyright © Thorsten Glaser <tglaser@b1-systems.de>
#
# Install to the check_mk server’s site’s user as:
# ~/local/lib/python3/cmk_addons/plugins/kea/agent_based/kea_dhcpd4.py
# Then run: cmk -R && omd restart || echo failed

"""kea_dhcpd4 check plugin for check_mk

It is assumed that only pools are of interest, not the subnets
that contain the pool, or shared-networks.

The agent is expected to use the line: <<<kea_dhcpd4:sep(9)>>>

Possible agent rows are all tab-separated fields with no quoting.
First column: 'g' (global), 's' (stat for a pool), 'p' (pool info)

Pools are identified by a (subnet-id, pool-number-within-subnet) tuple.

- 'g', 'err', str ⇒ handle as error (CRIT) on the DHCP service
- 'g', 'pid', int ⇒ info; error if absent
- 'g', 'uptime', int ⇒ info and possibly metric; error if absent
- 'g', 'socket', str ⇒ verify that present and == "'ready'"
- 's', snid, pid, 'err', str ⇒ handle as error on the pool service
- 's', snid, pid, 'ok', assigned:int, total:int
- 'g', '/s' ⇒ global error “incomplete stats” if absent
- 'p', snid, '*', 'err', str ⇒ handle as error on all pools of that subnet
- 'p', snid, pid, 'err', str ⇒ handle as error on the pool service
- 'p', snid, pid, 'ok', range:str
- 'g', '/p' ⇒ global error “incomplete pools” if absent
- 'g', '/g' ⇒ global error “incomplete agent output” if absent

All other lines are handled as global syntax error.

Service discovery works on all (snid, pid) tuples from both
's' and 'p' rows, plus a global one for the dæmon itself.

If there are stats for an (snid, pid) tuple but no pool info,
the correct service name cannot be constructed. This currently
is handled as both a global error and an (always-CRIT) service
with the three question marks as range in its name.

If there is pool info for an (snid, pid) tuple but no stats,
the corresponding pool service is CRIT.

(snid, '*') errors show up on the services for all pools of
that subnet, or on the global service if no corresponding
pools are known.
"""

import json

from cmk.agent_based.v2 import AgentSection, CheckPlugin, Result, \
  Service, State, render
from cmk.plugins.lib.dhcp_pools import check_dhcp_pools_levels

# escape a line array for errors: try JSON first, manual if not working
def _esc(v):
    try:
        return json.dumps(v, ensure_ascii=True, allow_nan=False,
          indent=None, separators=(",", ":"), sort_keys=False)
    except Exception as ex:
        if not isinstance(v, list):
            v = [v]
        v = '|'.join([repr(x) for x in v])
        return '(error{%s} escaping [%s])' % (repr(ex), v)

# parser for agent output
def _parse(tbl):
    gerrs = []
    gpid = None
    guptime = None
    gsocket = None
    gends = {}
    pools = {}
    snerrs = {}
    for row in tbl:
        # IndexError (row too short) and ValueError (e.g. invalid int value)
        # are handled below, as are generic Exception|s, so that every line
        # gets individually parsed
        try:
            # another test below ensures lines are not too long
            maxlen = 99 # only bother for valid commands
            # first column contains the “command” group
            if row[0] == 'g':
                # global: ends of groups, errors and dæmon statistics
                if row[1][0] == '/':
                    gends[row[1]] = True
                    maxlen = 2
                elif row[1] == 'err':
                    gerrs.append(row[2])
                    maxlen = 3
                elif row[1] == 'pid':
                    gpid = int(row[2])
                    maxlen = 3
                elif row[1] == 'uptime':
                    guptime = int(row[2])
                    maxlen = 3
                elif row[1] == 'socket':
                    gsocket = row[2]
                    maxlen = 3
                else:
                    gerrs.append('invalid subcommand in agent line: ' + _esc(row))
            elif row[0] == 's':
                # statistics for pools
                snid = int(row[1])
                pid = int(row[2])
                pool = (snid, pid)
                if row[3] == 'err':
                    s = row[4]
                    maxlen = 5
                    if pool not in pools:
                        pools[pool] = {}
                    if 'err' not in pools[pool]:
                        pools[pool]['err'] = []
                    pools[pool]['err'].append(s)
                elif row[3] == 'ok':
                    ass = int(row[4])
                    tot = int(row[5])
                    maxlen = 6
                    statt = (ass, tot)
                    if pool not in pools:
                        pools[pool] = {}
                    if 'stat' in pools[pool]:
                        if 'err' not in pools[pool]:
                            pools[pool]['err'] = []
                        pools[pool]['err'].append('conflicting stats (got %s, ignoring %s)' % \
                          (repr(pools[pool]['stat']), repr(statt)))
                    else:
                        pools[pool]['stat'] = statt
                else:
                    gerrs.append('invalid subcommand in agent line: ' + _esc(row))
            elif row[0] == 'p':
                # per-pool information (i.e. range)
                snid = int(row[1])
                if row[2] == '*':
                    pid = None
                else:
                    pid = int(row[2])
                pool = (snid, pid)
                if row[3] == 'err':
                    s = row[4]
                    maxlen = 5
                    if pid is None:
                        if snid not in snerrs:
                            snerrs[snid] = []
                        snerrs[snid].append(s)
                    else:
                        if pool not in pools:
                            pools[pool] = {}
                        if 'err' not in pools[pool]:
                            pools[pool]['err'] = []
                        pools[pool]['err'].append(s)
                elif (row[3] == 'ok') and (pid is not None):
                    s = row[4]
                    maxlen = 5
                    s[0]  # ensure it is not empty
                    if pool not in pools:
                        pools[pool] = {}
                    if 'range' in pools[pool]:
                        gerrs.append('conflicting ranges (got %s, ignoring %s) for subnet %d pool %d' % \
                          (repr(pools[pool]['range']), repr(s), snid, pid))
                    else:
                        pools[pool]['range'] = s
                else:
                    gerrs.append('invalid subcommand in agent line: ' + _esc(row))
            else:
                gerrs.append('invalid command in agent line: ' + _esc(row))
            if len(row) > maxlen:
                gerrs.append('agent row too long: ' + _esc(row))
        except IndexError:
            gerrs.append('agent row too short: ' + _esc(row))
        except ValueError:
            gerrs.append('invalid value in agent line: ' + _esc(row))
        except Exception as ex:
            gerrs.append('%s while trying to parse agent line: %s' % \
              (repr(ex), _esc(row)))
    # add per-subnet errors to per-pool errors of all pools of that subnet
    subnets = set()
    for pool in pools.keys():
        (snid, pid) = pool
        subnets.add(snid)
        if snid in snerrs:
            for s in snerrs[snid]:
                if 'err' not in pools[pool]:
                    pools[pool]['err'] = []
                pools[pool]['err'].append(s)
    # if no such pools exist add to global errors instead
    for snid in snerrs.keys():
        if snid not in subnets:
            for s in snerrs[snid]:
                gerrs.append('subnet %d error: %s' % (snid, s))
    # validate expected globals are present
    if gpid is None:
        gerrs.append('missing PID, is the dæmon running?')
    if guptime is None:
        gerrs.append('missing uptime stat')
    if gsocket is None:
        gerrs.append('missing socket stat')
    elif gsocket != "'ready'":
        gerrs.append('socket error: %s' % gsocket)
    if '/s' not in gends:
        gerrs.append('incomplete stats')
    if '/p' not in gends:
        gerrs.append('incomplete pools')
    if '/g' not in gends:
        gerrs.append('incomplete agent output')
    # the parsed result
    section = {
        'pid': gpid,
        'uptime': guptime,
        'items': {},
    }
    for pool, pv in pools.items():
        (snid, pid) = pool
        iv = {}  # item value (parsed)
        # stats get copied, if absent an error generated
        if 'stat' in pv:
            iv['stat'] = pv['stat']
        else:
            if 'err' not in pv:
                pv['err'] = []
            pv['err'].append('missing statistics')
        # handle missing ranges as error
        if 'range' in pv:
            range = pv['range']
        else:
            if 'err' not in pv:
                pv['err'] = []
            pv['err'].append('missing range info')
            gerrs.append('missing info for subnet %d pool %d' % (snid, pid))
            range = '???'
        # errors get copied
        if 'err' in pv:
            iv['err'] = pv['err']
        # snid, pid, range only show up in the item name
        item = 'subnet %d pool %d (%s)' % (snid, pid, range)
        section['items'][item] = iv
    # global errors accumulated until here are also part of the parsed result
    section['err'] = gerrs
    return section

agent_section_kea_dhcpd4 = AgentSection(
    name='kea_dhcpd4',
    parse_function=_parse,
)

## kea_dhcpd4 service: KEA DHCP4 dæmon (global)

def _disco_global(section):
    yield Service()

def _check_global(section):
    # no errors? easy, OK with some infos
    if len(section['err']) == 0:
        # TODO: perhaps make something useful of uptime?
        yield Result(state=State.OK,
          summary='PID %d, up %s' % (
            section['pid'],
            render.timespan(section['uptime']),
          ),
          details='PID %d\nuptime %d seconds\nno errors' % (
            section['pid'],
            section['uptime'],
          ))
        return
    # errors present ⇒ put into summary and/or details what we have
    warn = []  # summary, comma+space-separated
    info = []  # details, new lines
    # PID or uptime may be absent (which is already marked as error, but cope)
    if section['pid'] is not None:
        s = 'PID %d' % section['pid']
        warn.append(s)
        info.append(s)
    if section['uptime'] is not None:
        s = 'uptime %d seconds' % section['uptime']
        warn.append('up %s' % render.timespan(section['uptime']))
        info.append('uptime %d seconds' % section['uptime'])
    # if it’s only one error make it easier to see
    if len(section['err']) == 1:
        warn.append(section['err'][0])
    else:
        warn.append('%d errors' % len(section['err']))
    info.append('%d errors:' % len(section['err']))
    # copy all the errors
    info.extend(section['err'])
    yield Result(state=State.CRIT,
      summary=', '.join(warn),
      details='\n'.join(info))

check_plugin_kea_dhcpd4 = CheckPlugin(
    name='kea_dhcpd4',
    service_name='KEA DHCP4 dæmon',
    discovery_function=_disco_global,
    check_function=_check_global,
)

## kea_dhcpd4_pools services: per-pool statistics

def _disco_pool(section):
    for pool in section['items'].keys():
        yield Service(item=pool)

def _check_pool(item, params, section):
    if item not in section['items']:
        yield Result(state=State.CRIT,
          summary='monitored pool is not present in agent data')
        return
    v = section['items'][item]
    if 'stat' in v:
        (ass, tot) = v['stat']
        yield from check_dhcp_pools_levels(tot - ass, ass, None, tot, params)
    # if the service for the dæmon itself is CRIT, make the pools’ not OK
    if len(section['err']) > 0:
        yield Result(state=State.WARN, summary='dæmon has errors')
    if 'err' in v:
        nerr = len(v['err'])
        nerrs = '%d errors' % nerr
        if nerr == 1:
            s = v['err'][0]
        else:
            s = nerrs
        l = [nerrs + ':']
        l.extend(v['err'])
        yield Result(state=State.CRIT, summary=s, details='\n'.join(l))

check_plugin_kea_dhcpd4_pools = CheckPlugin(
    name='kea_dhcpd4_pools',
    sections=['kea_dhcpd4'],
    service_name='DHCP %s',
    discovery_function=_disco_pool,
    check_function=_check_pool,
    # same as in isc_dhcpd.py
    check_ruleset_name='win_dhcp_pools',
    check_default_parameters={'free_leases': (15.0, 5.0)},
)
