Poll loop now deletes DB rows for players no longer returned by the Yodeck API, and Zabbix sync deletes the corresponding hosts from the Yodeck Players group. Both actions are reflected in the activity log. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""
|
|
Zabbix API client for automatic host management.
|
|
|
|
Each Yodeck player becomes a Zabbix host with:
|
|
- hostname : yodeck-<yodeck_id>
|
|
- visible name: "QRS-" + first 4 chars + "MB-" + remainder (e.g. AMS-COF1 -> QRS-AMS-MB-COF1)
|
|
- SNMP v2c interface pointing to this app (APP_HOST:161)
|
|
- Four SNMP items: online, last_seen, updating, registered
|
|
"""
|
|
import logging
|
|
import requests
|
|
from app.config import (
|
|
ZABBIX_URL, ZABBIX_API_TOKEN, ZABBIX_USER, ZABBIX_PASSWORD,
|
|
ZABBIX_HOST_GROUP, ZABBIX_TEMPLATE, ZABBIX_SNMP_COMMUNITY,
|
|
APP_HOST, ENTERPRISE_OID,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
SNMP_PORT = 161
|
|
|
|
# OID column indices (must match snmp/pass_persist.py)
|
|
COL_HOSTNAME = 1
|
|
COL_NAME = 2
|
|
COL_ONLINE = 3
|
|
COL_LAST_SEEN = 4
|
|
COL_UPDATING = 5
|
|
COL_REGISTERED = 6
|
|
|
|
|
|
class ZabbixClient:
|
|
def __init__(self):
|
|
self._url = f"{ZABBIX_URL.rstrip('/')}/api_jsonrpc.php"
|
|
self._auth = None
|
|
self._id = 0
|
|
|
|
def _call(self, method, params):
|
|
self._id += 1
|
|
payload = {'jsonrpc': '2.0', 'method': method, 'params': params, 'id': self._id}
|
|
headers = {}
|
|
if ZABBIX_API_TOKEN:
|
|
# Zabbix 6.4+: API tokens go in the Authorization header, not the payload
|
|
headers['Authorization'] = f'Bearer {ZABBIX_API_TOKEN}'
|
|
elif self._auth:
|
|
# Zabbix < 6.4 / user+password sessions: auth goes in the JSON-RPC payload
|
|
payload['auth'] = self._auth
|
|
resp = requests.post(self._url, json=payload, headers=headers, timeout=30)
|
|
resp.raise_for_status()
|
|
body = resp.json()
|
|
if 'error' in body:
|
|
raise RuntimeError(f"Zabbix [{method}]: {body['error'].get('data', body['error'])}")
|
|
return body['result']
|
|
|
|
def login(self):
|
|
if ZABBIX_API_TOKEN:
|
|
pass # token is sent via Authorization header in every _call — no login needed
|
|
else:
|
|
self._auth = self._call('user.login', {'user': ZABBIX_USER, 'password': ZABBIX_PASSWORD})
|
|
|
|
def logout(self):
|
|
if ZABBIX_API_TOKEN:
|
|
return # API tokens have no session to invalidate
|
|
try:
|
|
self._call('user.logout', [])
|
|
except Exception:
|
|
pass
|
|
self._auth = None
|
|
|
|
# ------------------------------------------------------------------ groups
|
|
|
|
def ensure_hostgroup(self, name):
|
|
existing = self._call('hostgroup.get', {'filter': {'name': name}, 'output': ['groupid']})
|
|
if existing:
|
|
return existing[0]['groupid']
|
|
try:
|
|
return self._call('hostgroup.create', {'name': name})['groupids'][0]
|
|
except RuntimeError as exc:
|
|
if 'permission' in str(exc).lower():
|
|
raise RuntimeError(
|
|
f"Host group '{name}' not found or not visible to the API token user. "
|
|
f"Fix in Zabbix: Administration -> User groups -> [your group] -> "
|
|
f"Permissions -> add '{name}' with Read-write access. "
|
|
f"Alternatively assign Super admin role to the API token user."
|
|
) from exc
|
|
raise
|
|
|
|
# ------------------------------------------------------------------ hosts
|
|
|
|
def get_hosts_in_group(self, groupid):
|
|
return self._call('host.get', {
|
|
'groupids': groupid,
|
|
'output': ['hostid', 'host', 'name'],
|
|
'selectParentTemplates': ['templateid'],
|
|
})
|
|
|
|
def create_host(self, hostname, visible_name, groupid):
|
|
result = self._call('host.create', {
|
|
'host': hostname,
|
|
'name': visible_name,
|
|
'groups': [{'groupid': groupid}],
|
|
'interfaces': [{
|
|
'type': 2, # SNMP
|
|
'main': 1,
|
|
'useip': 1,
|
|
'ip': APP_HOST,
|
|
'dns': '',
|
|
'port': str(SNMP_PORT),
|
|
'details': {
|
|
'version': 2, # SNMPv2c
|
|
'community': ZABBIX_SNMP_COMMUNITY,
|
|
'bulk': 1,
|
|
},
|
|
}],
|
|
})
|
|
return result['hostids'][0]
|
|
|
|
def update_host_name(self, hostid, visible_name):
|
|
self._call('host.update', {'hostid': hostid, 'name': visible_name})
|
|
|
|
def delete_hosts(self, hostids):
|
|
self._call('host.delete', hostids)
|
|
|
|
# ------------------------------------------------------------------ templates
|
|
|
|
def get_template_id(self, name):
|
|
# Search by technical name (host field) — case-insensitive search with exact-match fallback
|
|
result = self._call('template.get', {
|
|
'output': ['templateid', 'host'],
|
|
'search': {'host': name},
|
|
'searchByAny': True,
|
|
})
|
|
# Exact match from search results
|
|
for t in result:
|
|
if t['host'] == name:
|
|
return t['templateid']
|
|
# Log what IS visible to help diagnose permission issues
|
|
if result:
|
|
visible = [t['host'] for t in result]
|
|
log.warning("Template '%s' not found; visible templates: %s", name, visible)
|
|
else:
|
|
all_tpl = self._call('template.get', {'output': ['host'], 'limit': 5})
|
|
log.warning(
|
|
"Template '%s' not found and API token can see %d template(s) total (first 5: %s). "
|
|
"Fix in Zabbix: Administration → User groups → [your group] → "
|
|
"Template permissions → add 'Templates' group with Read access.",
|
|
name,
|
|
len(all_tpl),
|
|
[t['host'] for t in all_tpl],
|
|
)
|
|
return None
|
|
|
|
def link_template(self, hostid, templateid):
|
|
# host.update with templates replaces the list — fetch existing first to avoid unlinking others
|
|
existing = self._call('host.get', {
|
|
'hostids': hostid,
|
|
'selectParentTemplates': ['templateid'],
|
|
'output': [],
|
|
})
|
|
current = [{'templateid': t['templateid']} for t in existing[0].get('parentTemplates', [])]
|
|
if not any(t['templateid'] == templateid for t in current):
|
|
self._call('host.update', {'hostid': hostid, 'templates': current + [{'templateid': templateid}]})
|
|
|
|
def upsert_host_macro(self, hostid, macro, value):
|
|
existing = self._call('usermacro.get', {
|
|
'hostids': hostid,
|
|
'filter': {'macro': macro},
|
|
'output': ['hostmacroid'],
|
|
})
|
|
if existing:
|
|
self._call('usermacro.update', {'hostmacroid': existing[0]['hostmacroid'], 'value': value})
|
|
else:
|
|
self._call('usermacro.create', {'hostid': hostid, 'macro': macro, 'value': value})
|
|
|
|
# ------------------------------------------------------------------ items
|
|
|
|
def _get_interface_id(self, hostid):
|
|
ifaces = self._call('hostinterface.get', {
|
|
'hostids': hostid, 'output': ['interfaceid'],
|
|
})
|
|
return ifaces[0]['interfaceid'] if ifaces else None
|
|
|
|
def _create_item(self, hostid, interfaceid, name, key, oid, value_type):
|
|
"""
|
|
type 20 = SNMP agent (Zabbix 6.0+)
|
|
value_type 3 = unsigned int, 4 = text
|
|
"""
|
|
try:
|
|
self._call('item.create', {
|
|
'hostid': hostid,
|
|
'interfaceid': interfaceid,
|
|
'name': name,
|
|
'key_': key,
|
|
'type': 20,
|
|
'snmp_oid': oid,
|
|
'value_type': value_type,
|
|
'delay': '1m',
|
|
'history': '90d',
|
|
})
|
|
except Exception as exc:
|
|
log.warning("Could not create item '%s' on host %s: %s", name, hostid, exc)
|
|
|
|
def add_player_items(self, hostid, yodeck_id):
|
|
ifid = self._get_interface_id(hostid)
|
|
if not ifid:
|
|
log.warning("No SNMP interface found for host %s", hostid)
|
|
return
|
|
base = f"{ENTERPRISE_OID}.1.1"
|
|
self._create_item(hostid, ifid, 'Online', 'yodeck.online', f'{base}.{COL_ONLINE}.{yodeck_id}', value_type=3)
|
|
self._create_item(hostid, ifid, 'Last Seen', 'yodeck.last_seen', f'{base}.{COL_LAST_SEEN}.{yodeck_id}', value_type=4)
|
|
self._create_item(hostid, ifid, 'Updating', 'yodeck.updating', f'{base}.{COL_UPDATING}.{yodeck_id}', value_type=3)
|
|
self._create_item(hostid, ifid, 'Registered', 'yodeck.registered', f'{base}.{COL_REGISTERED}.{yodeck_id}', value_type=3)
|
|
|
|
|
|
def sync_to_zabbix(players, add_log_fn):
|
|
"""Sync player list to Zabbix: create missing hosts, update names, link template."""
|
|
if not ZABBIX_URL:
|
|
log.debug("ZABBIX_URL not configured — skipping Zabbix sync")
|
|
return
|
|
|
|
zbx = ZabbixClient()
|
|
try:
|
|
zbx.login()
|
|
groupid = zbx.ensure_hostgroup(ZABBIX_HOST_GROUP)
|
|
template_id = zbx.get_template_id(ZABBIX_TEMPLATE) if ZABBIX_TEMPLATE else None
|
|
if ZABBIX_TEMPLATE and not template_id:
|
|
msg = f"Template '{ZABBIX_TEMPLATE}' not found in Zabbix — import zabbix_template.yaml first"
|
|
log.warning(msg)
|
|
add_log_fn('error', msg)
|
|
existing = {h['host']: h for h in zbx.get_hosts_in_group(groupid)}
|
|
|
|
created = updated = skipped = 0
|
|
for player in players:
|
|
yid = player['id']
|
|
hostname = f"yodeck-{yid}"
|
|
visible = f"QRS-{player['name'][:4]}MB-{player['name'][4:]}"
|
|
|
|
try:
|
|
if hostname not in existing:
|
|
hostid = zbx.create_host(hostname, visible, groupid)
|
|
zbx.upsert_host_macro(hostid, '{$YODECK_ID}', str(yid))
|
|
if template_id:
|
|
zbx.link_template(hostid, template_id)
|
|
else:
|
|
zbx.add_player_items(hostid, yid)
|
|
created += 1
|
|
log.info("Created Zabbix host: %s (%s)", hostname, visible)
|
|
else:
|
|
h = existing[hostname]
|
|
changed = False
|
|
if h['name'] != visible:
|
|
zbx.update_host_name(h['hostid'], visible)
|
|
changed = True
|
|
if template_id:
|
|
linked = {t['templateid'] for t in h.get('parentTemplates', [])}
|
|
if template_id not in linked:
|
|
zbx.upsert_host_macro(h['hostid'], '{$YODECK_ID}', str(yid))
|
|
zbx.link_template(h['hostid'], template_id)
|
|
log.info("Linked template to existing host: %s", hostname)
|
|
changed = True
|
|
if changed:
|
|
updated += 1
|
|
except Exception as exc:
|
|
log.warning("Skipped host %s: %s", hostname, exc)
|
|
skipped += 1
|
|
|
|
current_hostnames = {f"yodeck-{p['id']}" for p in players}
|
|
stale = [h for h in existing.values() if h['host'] not in current_hostnames]
|
|
deleted = 0
|
|
for h in stale:
|
|
try:
|
|
zbx.delete_hosts([h['hostid']])
|
|
deleted += 1
|
|
log.info("Deleted stale Zabbix host: %s", h['host'])
|
|
except Exception as exc:
|
|
log.warning("Could not delete host %s: %s", h['host'], exc)
|
|
skipped += 1
|
|
|
|
msg = (f"Zabbix sync complete: {created} created, {updated} updated"
|
|
+ (f", {deleted} deleted" if deleted else "")
|
|
+ (f", {skipped} skipped" if skipped else "")
|
|
+ f" ({len(players)} total)")
|
|
add_log_fn('zabbix_sync', msg, {'created': created, 'updated': updated, 'deleted': deleted, 'total': len(players)})
|
|
log.info(msg)
|
|
|
|
except Exception as exc:
|
|
msg = f"Zabbix sync failed: {exc}"
|
|
add_log_fn('error', msg)
|
|
log.error(msg)
|
|
finally:
|
|
zbx.logout()
|