- zabbix_template.yaml: importable template "Yodmon Yodeck Player"
- 5 SNMP items using {$YODECK_ID} host macro for per-host OIDs
- Trigger: offline warning after 30 minutes (yodeck.online < 1)
- Trigger: last seen > 4 hours (now() - yodeck.last_seen_ts > 14400)
- snmp/pass_persist.py: add col 7 — last_seen as Unix timestamp
- app/zabbix.py: link hosts to template, upsert {$YODECK_ID} macro;
existing hosts get template linked on next sync automatically
- app/config.py: add ZABBIX_TEMPLATE setting
Import zabbix_template.yaml once via Zabbix UI, then redeploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
9.6 KiB
Python
241 lines
9.6 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})
|
|
|
|
# ------------------------------------------------------------------ templates
|
|
|
|
def get_template_id(self, name):
|
|
result = self._call('template.get', {'filter': {'host': name}, 'output': ['templateid']})
|
|
return result[0]['templateid'] if result else 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:
|
|
log.warning("Template '%s' not found in Zabbix — import zabbix_template.yaml first", ZABBIX_TEMPLATE)
|
|
existing = {h['host']: h for h in zbx.get_hosts_in_group(groupid)}
|
|
|
|
created = updated = 0
|
|
for player in players:
|
|
yid = player['id']
|
|
hostname = f"yodeck-{yid}"
|
|
visible = f"QRS-{player['name'][:4]}MB-{player['name'][4:]}"
|
|
|
|
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]
|
|
if h['name'] != visible:
|
|
zbx.update_host_name(h['hostid'], visible)
|
|
updated += 1
|
|
# Link template and set macro on existing hosts that don't have it yet
|
|
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)
|
|
updated += 1
|
|
|
|
msg = f"Zabbix sync complete: {created} created, {updated} updated ({len(players)} total)"
|
|
add_log_fn('zabbix_sync', msg, {'created': created, 'updated': updated, '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()
|