69 Commits

Author SHA1 Message Date
1d5e59810f 1219 2026-04-02 13:26:35 +03:00
c223ae7043 1219 2026-04-01 12:19:01 +03:00
94cf4cabe3 1036 2026-03-27 10:36:30 +02:00
ae501fbd3a 0529 2026-03-27 05:29:52 +02:00
6af07445f0 0519 2026-03-27 05:19:26 +02:00
4e23aa5e8d 1821 2026-03-23 18:22:00 +02:00
a79472bacc 1808 2026-03-23 18:08:48 +02:00
fc1f2726a0 1802 2026-03-23 18:02:18 +02:00
5e06d58a10 1744 2026-03-23 17:44:31 +02:00
674f7c84f9 1735 2026-03-23 17:35:41 +02:00
26450a767b 1321 2026-03-23 13:21:09 +02:00
25620eff65 0750 2026-03-23 07:50:17 +02:00
6a4df2db0f 0743 2026-03-23 07:43:33 +02:00
c65a40884c 2227 2026-03-22 22:27:30 +02:00
801b91b85f 1929 2026-03-22 19:29:41 +02:00
dfaf9d19b4 1208 2026-03-21 12:08:00 +02:00
b11f00c827 0656 2026-03-21 06:56:25 +02:00
2a74c52b7b 0914 2026-03-20 09:14:14 +02:00
dc9c6e721c 1404 2026-03-19 14:04:00 +02:00
9c6cbbdf5b 1215 2026-03-19 12:15:00 +02:00
f6722b491c 1146 2026-03-19 11:46:05 +02:00
5fd5f85641 0538 2026-03-19 05:39:03 +02:00
b30cb221e8 2348 2026-03-18 23:48:40 +02:00
2d966d19e7 2336 2026-03-18 23:36:26 +02:00
58aa97f10c 1609 2026-03-18 16:09:42 +02:00
938b76be02 1102 2026-03-17 11:02:45 +02:00
3dae12ad23 1855 2026-03-16 18:55:04 +02:00
25cebe140b 0906 2026-03-16 09:06:18 +02:00
428627717b 2024 2026-03-15 20:24:13 +02:00
22f6ba7727 1139 2026-03-15 11:39:57 +02:00
3747888a5c 0622 2026-03-15 06:22:23 +02:00
1f3827cb6e 1924 2026-03-14 19:24:07 +02:00
0930e7ea97 1016 2026-03-13 10:16:54 +02:00
8b45630ee4 1017 2026-03-12 10:17:29 +02:00
9eb245a517 0539 2026-03-12 05:39:32 +02:00
eaa8117f29 1549 2026-03-11 15:49:54 +02:00
6d19ad2814 1022 2026-03-11 10:22:28 +02:00
b3eee11104 0042 2026-03-11 00:42:53 +02:00
e1e36f82f9 1606 2026-03-10 16:06:40 +02:00
584348253c 1517 2026-03-09 15:17:15 +02:00
fc476be2cc 1514 2026-03-09 15:14:31 +02:00
a963a9e0f7 0823 2026-03-09 08:28:28 +02:00
7361a85f02 0823 2026-03-09 08:23:49 +02:00
2fd1f920c0 1210 2026-03-07 12:10:09 +02:00
9f40495e96 1208 2026-03-07 12:08:54 +02:00
c48a80948b 1720 2026-03-05 17:20:51 +02:00
8c7a9cc6c9 1702 2026-03-05 17:02:47 +02:00
4f184cfdf6 1557 2026-02-25 15:57:19 +02:00
49eb9fd262 1838 2026-02-24 18:38:05 +02:00
c70fa140b8 1756 2026-02-24 17:56:04 +02:00
94e55887a7 1329 2026-02-15 13:29:01 +02:00
c268337621 1039 2026-02-15 10:39:13 +02:00
7cb47d2188 1026 2026-02-15 10:26:21 +02:00
d3c3a32e7c 0957 2026-02-15 09:57:13 +02:00
ef4820661c 0821 2026-02-15 08:21:09 +02:00
80d23b669a 0807 2026-02-15 08:07:29 +02:00
0859450ac4 1553 2026-02-13 15:53:47 +02:00
17606178ce 1551 2026-02-13 15:51:30 +02:00
4578d443a2 1550 2026-02-13 15:50:56 +02:00
0fe60b95f8 0656 2026-02-13 06:56:13 +02:00
99bc927ea2 0851 2026-02-12 08:51:54 +02:00
07c1a9bb22 1751 2026-02-11 17:51:27 +02:00
7004c0b9bb 1745 2026-02-11 17:45:13 +02:00
5c03bce9cc 1458 2026-02-11 14:58:11 +02:00
5d19eb0f26 1450 2026-02-11 14:50:30 +02:00
46ea4359ed 1435 2026-02-11 14:35:50 +02:00
ansible user
c2a8d3fc44 tweaks 2026-02-11 14:33:44 +02:00
e48e2f28fc 20:42 2025-11-05 20:42:39 +02:00
f2600d2bb6 20:38 2025-11-05 20:38:35 +02:00
5 changed files with 2376 additions and 75 deletions

1
.env
View File

@@ -56,3 +56,4 @@ RrJEcyns1Qnh7/5+ZOY/UhU4h++uBuUVacH4DmOJgbxae/OXNAMcpbvYM2SZ6KfR
# Only disable hostname verification if absolutely necessary:
TLS_INSECURE=1
NB_LOOKUP_CACHE_TTL=120

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data/

View File

@@ -0,0 +1,711 @@
#!/usr/bin/env python3
"""
NATS Registration Listener (fox100 + NetBox hostname/action_next lookup + timing + problem counter)
-------------------------------------------------------------------------------------------
- One device GET (status + tags + custom_fields.action_next), no duplicate fetch
- Keeps: nb_problems counter, timings, iface_id diagnostics, same formatting
- For fox100:
* If action_next is empty/absent -> print a simple stdout note and do nothing else
* If action_next present and action_last != action_next -> publish task_name=action_next
and on success set action_last=action_next and action_next_timestamp=now_epoch
* If action_next present and action_last == action_next -> publish only if
(now_epoch - action_next_timestamp) >= 600; if timestamp missing/invalid -> allow publish
and on success set action_last=action_next and action_next_timestamp=now_epoch
* If action_next present -> prepend 3x ASCII BEL to stdout line (kept behavior)
"""
import argparse
import asyncio
import json
import os
import signal
import ssl
import sys
import hashlib
import time
from time import monotonic
from datetime import datetime, timezone
from typing import Optional, Dict, Any, Tuple, Set
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import base64 # for RabbitMQ Basic Auth
import nats
# =========================
# NetBox hardcoded config
# =========================
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623" # keep as provided
NB_TIMEOUT = 3.0 # seconds per HTTP GET
# Cache for MAC -> NetBox lookup result (seconds). Keeps NetBox load down under chatty devices.
NB_LOOKUP_CACHE_TTL = float(os.environ.get("NB_LOOKUP_CACHE_TTL", "10.0"))
NB_LOOKUP_CACHE = {} # mac_norm -> (expires_monotonic, cached_tuple)
# =========================
# RabbitMQ hardcoded config
# =========================
RMQ_HOST = "10.210.12.2"
RMQ_PORT = 15672
RMQ_USER = "admin"
RMQ_PASS = "change_me"
RMQ_VHOST = "app"
RMQ_EXCHANGE_WORK = "deviceconfig" # direct exchange (immediate)
RMQ_EXCHANGE_DELAYED = "deviceconfig.delayed" # delayed exchange (x-delayed-message)
RMQ_ROUTING_KEY = "deviceconfig"
RMQ_TIMEOUT = 3.0
# ---- Human-editable delay (milliseconds). Set to 0 to disable delay.
# Example: 600000 = 10 minutes
RMQ_DELAY_MS = 15000
# Product -> Tag slug mapping (kept unchanged, though not used now)
PRODUCT_TAG_SLUG = {
"fox100": "fox100-auto-upgrade-latest",
# "fox200": "fox200-auto-upgrade-latest",
}
# Global counter for any NetBox-related problems
NB_PROBLEM_COUNTER = 0
NB_PROBLEM_LOCK = asyncio.Lock()
# =========================
# Arg parsing
# =========================
def parse_args():
p = argparse.ArgumentParser(description="Listen to a NATS subject and print one line per device.")
p.add_argument("--servers", nargs="+", default=["nats://127.0.0.1:4222"])
p.add_argument("--subject", default="registration")
p.add_argument("--queue", default=None)
p.add_argument("--name", default="registration-listener")
p.add_argument("--creds")
p.add_argument("--user")
p.add_argument("--password")
p.add_argument("--token")
p.add_argument("--tls-ca")
p.add_argument("--tls-cert")
p.add_argument("--tls-key")
p.add_argument("--insecure", action="store_true")
p.add_argument("--include-subject", action="store_true")
return p.parse_args()
# =========================
# Helpers
# =========================
def make_ssl_context(args) -> Optional[ssl.SSLContext]:
if not any([args.tls_ca, args.tls_cert, args.tls_key]) and not any(url.startswith("tls://") for url in args.servers):
return None
ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
if args.tls_ca:
ctx.load_verify_locations(args.tls_ca)
if args.tls_cert and args.tls_key:
ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key)
if args.insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def ts() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S%z")
def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if "eth0" in eths and isinstance(eths["eth0"], dict):
return eths["eth0"]
for name in sorted(eths.keys()):
if isinstance(eths[name], dict):
return eths[name]
return None
def extract_fields(obj: Dict[str, Any]):
root = obj
d = root["data"] if isinstance(root.get("data"), dict) else root
product = d.get("productName") or "-"
fw_active = d.get("firmwareVersion", {}).get("active") or "-"
mac = "-"
eths = d.get("ethernetInterfaces", {})
if isinstance(eths, dict):
chosen = pick_first_interface(eths)
if chosen and isinstance(chosen.get("macAddress"), str):
mac = chosen["macAddress"]
return product, mac, fw_active
# =========================
# NetBox lookup (urllib)
# =========================
def normalize_mac(mac: str) -> Optional[str]:
if not mac or not isinstance(mac, str):
return None
s = mac.strip().lower().replace("-", ":")
hex_only = "".join(ch for ch in s if ch in "0123456789abcdef")
if len(hex_only) == 12:
return ":join".replace(":", "").join([":".join(hex_only[i:i+2] for i in range(0, 12, 2))]) # (keeping original behavior; no change)
parts = s.split(":")
if len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts):
return s
return None
def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
if params:
url = f"{url}?{urlencode(params)}"
req = Request(url, headers=headers or {}, method="GET")
try:
with urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return None, resp.status
data = resp.read()
return json.loads(data.decode("utf-8", errors="replace")), 200
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
# RabbitMQ management API POST helper (basic auth; JSON in/out)
def http_post_json(url: str, payload_obj: Dict[str, Any], user: Optional[str] = None, password: Optional[str] = None, timeout: float = RMQ_TIMEOUT):
body = json.dumps(payload_obj).encode("utf-8")
headers = {"Content-Type": "application/json"}
if user and password:
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {token}"
req = Request(url, data=body, headers=headers, method="POST")
try:
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), resp.status
except Exception:
return None, resp.status
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
# NetBox PATCH helper (JSON in/out)
def http_patch_json(url: str, payload_obj: Dict[str, Any], headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
body = json.dumps(payload_obj).encode("utf-8")
h = dict(headers or {})
h["Content-Type"] = "application/json"
req = Request(url, data=body, headers=h, method="PATCH")
try:
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), resp.status
except Exception:
return None, resp.status
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
async def nb_problem(log_status, msg: str):
"""Increment counter and log a problem line."""
global NB_PROBLEM_COUNTER
async with NB_PROBLEM_LOCK:
NB_PROBLEM_COUNTER += 1
await log_status(msg)
def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[
Optional[str], Optional[int], Optional[int], Optional[str], Optional[Set[str]], Optional[Any], Optional[Any], Optional[Any], Optional[Any]
]:
"""
Resolve MAC -> (hostname, iface_id, device_id, device_status_value, tag_slugs_set, action_next, action_last, action_next_timestamp, action_state)
- Logs problems for anomalies (mac not found, unassigned, wrong type, iface fetch fail).
- If device detail fetch fails, returns host/id with status/tags/custom_fields as None.
"""
mac_norm = normalize_mac(mac)
if not mac_norm:
return None, None, None, None, None, None, None, None, None
# Step 0: short TTL cache (avoid repeated NetBox GETs for chatty devices)
if NB_LOOKUP_CACHE_TTL > 0:
nowm = monotonic()
cached = NB_LOOKUP_CACHE.get(mac_norm)
if cached:
exp, val = cached
if exp > nowm:
host, iface_id, dev_id, status_val, tag_slugs, action_next, action_last, action_next_timestamp, action_state = val
if tag_slugs is not None:
try:
tag_slugs = set(tag_slugs)
except Exception:
pass
return host, iface_id, dev_id, status_val, tag_slugs, action_next, action_last, action_next_timestamp, action_state
NB_LOOKUP_CACHE.pop(mac_norm, None)
base = NB_URL.rstrip("/")
h = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
# Step 1: MAC lookup
data, code = http_get_json(
f"{base}/api/dcim/mac-addresses/",
params={"mac_address": mac_norm, "limit": "2", "fields": "assigned_object_type,assigned_object_id"},
headers=h,
)
if code == 400:
data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h)
if code != 200 or not data:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac query http={code} mac={mac_norm}"))
return None, None, None, None, None, None, None, None, None
results = (data or {}).get("results") or []
if not results:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac not found mac={mac_norm}"))
return None, None, None, None, None, None, None, None, None
rec = results[0]
aot = (rec.get("assigned_object_type") or "").strip()
aoid = rec.get("assigned_object_id")
if len(results) > 1:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: multiple mac records mac={mac_norm} iface_id={aoid if aoid is not None else '-'}"))
if not aot or aoid is None:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac unassigned mac={mac_norm}"))
return None, None, None, None, None, None, None, None, None
if aot != "dcim.interface":
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm} iface_id={aoid}"))
return None, aoid, None, None, None, None, None, None, None
# Step 2: Interface -> Device (shallow)
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", params={"fields": "device"}, headers=h)
if code2 == 400:
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h)
if code2 != 200 or not iface:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}"))
return None, aoid, None, None, None, None, None, None, None
dev = iface.get("device") or {}
host = dev.get("name") or dev.get("display")
dev_id = dev.get("id")
if not host or dev_id is None:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface has no device iface_id={aoid}"))
return None, aoid, None, None, None, None, None, None, None
# Step 3: Device detail (single fetch for status, tags, custom_fields.*)
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", params={"fields": "status,tags,custom_fields"}, headers=h)
if code3 == 400:
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", headers=h)
if code3 != 200 or not device:
# treat as "no extra info"
return host, aoid, dev_id, None, None, None, None, None, None
status_val = ((device.get("status") or {}).get("value")) or None
tags = device.get("tags") or []
tag_slugs = set()
for t in tags:
slug = t.get("slug")
if isinstance(slug, str):
tag_slugs.add(slug)
cf = device.get("custom_fields") or {}
action_next = cf.get("action_next")
action_last = cf.get("action_last")
action_next_timestamp = cf.get("action_next_timestamp")
action_state = cf.get("action_state")
# Step 4: populate cache (only on full success)
if NB_LOOKUP_CACHE_TTL > 0:
try:
NB_LOOKUP_CACHE[mac_norm] = (
monotonic() + NB_LOOKUP_CACHE_TTL,
(
host,
aoid,
dev_id,
(status_val if isinstance(status_val, str) else None),
(tuple(tag_slugs) if tag_slugs is not None else None),
action_next,
action_last,
action_next_timestamp,
action_state,
),
)
except Exception:
pass
return host, aoid, dev_id, (status_val if isinstance(status_val, str) else None), tag_slugs, action_next, action_last, action_next_timestamp, action_state
# =========================
# Main
# =========================
async def main():
args = parse_args()
ssl_ctx = make_ssl_context(args)
print_lock = asyncio.Lock()
async def log_status(s: str):
async with print_lock:
print(s, file=sys.stderr, flush=True)
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
recent_payloads: Dict[bytes, float] = {}
async def disconnected_cb():
await log_status(f"[{ts()}] Disconnected from NATS.")
async def reconnected_cb():
await log_status(f"[{ts()}] Reconnected to NATS.")
async def error_cb(e):
await log_status(f"[{ts()}] Error: {e!r}")
async def closed_cb():
await log_status(f"[{ts()}] Connection closed.")
nc = await nats.connect(
servers=args.servers,
name=args.name,
allow_reconnect=True,
reconnect_time_wait=2,
max_reconnect_attempts=-1,
disconnected_cb=disconnected_cb,
reconnected_cb=reconnected_cb,
error_cb=error_cb,
closed_cb=closed_cb,
user_credentials=args.creds if args.creds else None,
user=args.user,
password=args.password,
token=args.token,
tls=ssl_ctx,
)
async def message_handler(msg: nats.aio.msg.Msg):
t_start = monotonic()
payload = msg.data
# strict payload dedupe
digest = hashlib.blake2b(payload, digest_size=16).digest()
nowm = monotonic()
exp = recent_payloads.get(digest)
if exp and exp > nowm:
return
recent_payloads[digest] = nowm + DEDUPE_TTL
if len(recent_payloads) > 4096:
cutoff = nowm
for k in list(recent_payloads.keys()):
if recent_payloads[k] <= cutoff:
recent_payloads.pop(k, None)
product = mac = fw = "-"
try:
text = payload.decode("utf-8", errors="replace")
obj = json.loads(text)
product, mac, fw = extract_fields(obj)
except Exception:
pass
host_suffix = ""
action_suffix = "" # kept; not used
bell_prefix = "" # ASCII BEL when action_next present (3x)
netbox_time_ms = 0.0
if product == "fox100":
nb_start = time.perf_counter()
try:
host, iface_id, dev_id, status_val, tag_slugs, action_next, action_last, action_next_timestamp, action_state = nb_lookup_device_by_mac(
mac=mac, log_status=log_status
)
if host:
host_suffix = f" host={host}"
if iface_id and not host:
host_suffix += f" iface_id={iface_id}"
if host:
# Gate on action_state: allow only "" or "ready"
action_state_str = ""
try:
if action_state is None:
action_state_str = ""
elif isinstance(action_state, str):
action_state_str = action_state.strip()
else:
action_state_str = str(action_state).strip()
except Exception:
action_state_str = ""
if action_state_str not in ("", "ready", "done"):
async with print_lock:
print(
f"[{ts()}] device is not ready because of action_state host={host} action_state={action_state_str}",
file=sys.stdout,
flush=True,
)
return
# Determine if action_next is present (non-empty string, or any truthy value)
has_action_next = False
action_next_str = None
try:
if isinstance(action_next, str):
action_next_str = action_next.strip()
has_action_next = len(action_next_str) > 0
else:
has_action_next = bool(action_next)
if has_action_next:
action_next_str = str(action_next)
except Exception:
has_action_next = False
action_next_str = None
if not has_action_next:
# User-requested behavior: if no action -> just shoot a message to stdout and we're ok
async with print_lock:
print(f"[{ts()}] no action_next for host={host}", file=sys.stdout, flush=True)
else:
# Compare action_last with action_next (strings)
action_last_str = None
try:
if isinstance(action_last, str):
action_last_str = action_last.strip()
elif action_last is None:
action_last_str = None
else:
action_last_str = str(action_last)
except Exception:
action_last_str = None
now_epoch = int(time.time())
# If action_last == action_next, apply cooldown based on action_next_timestamp (600s)
if action_last_str == action_next_str:
allow_repeat = True
try:
if action_next_timestamp is None:
allow_repeat = True
elif isinstance(action_next_timestamp, (int, float)):
allow_repeat = (now_epoch - int(action_next_timestamp)) >= 600
elif isinstance(action_next_timestamp, str):
allow_repeat = (now_epoch - int(action_next_timestamp.strip())) >= 600
else:
allow_repeat = True
except Exception:
allow_repeat = True
if not allow_repeat:
async with print_lock:
print(
f"[{ts()}] cooldown action_next for host={host} task={action_next_str}",
file=sys.stdout,
flush=True,
)
else:
# Publish task_name=action_next
effective_delay_ms = RMQ_DELAY_MS
target_exchange = RMQ_EXCHANGE_DELAYED if effective_delay_ms > 0 else RMQ_EXCHANGE_WORK
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{target_exchange}/publish"
payload_obj = {
"inscope_device": host,
"task_name": action_next_str,
}
payload_raw = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": payload_raw,
"payload_encoding": "string",
}
if effective_delay_ms > 0:
rmq_body["properties"]["headers"] = {"x-delay": int(effective_delay_ms)}
await log_status(
f"[{ts()}] ok, here i will execute\n"
f" url: {rmq_url}\n"
f" routing_key: {RMQ_ROUTING_KEY}\n"
f" payload_raw: {payload_raw}\n"
f" publish_body: {json.dumps(rmq_body, ensure_ascii=False)}"
)
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
await log_status(f"[{ts()}] rmq: publish http={code} host={host}")
else:
routed = False
try:
routed = bool((resp or {}).get("routed", False))
except Exception:
routed = False
# ---- SURGICAL FIX ----
# For delayed publishes (effective_delay_ms > 0), routed may be false but the message is accepted.
publish_ok = True
if effective_delay_ms <= 0 and not routed:
publish_ok = False
await log_status(f"[{ts()}] rmq: publish immediate routed=false host={host}")
# ----------------------
if publish_ok:
# On success: set action_last and action_next_timestamp and action_state
try:
base = NB_URL.rstrip("/")
nb_headers = {
"Accept": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
patch_body = {"custom_fields": {"action_last": action_next_str, "action_next_timestamp": str(now_epoch), "action_state": "started"}}
_, pcode = http_patch_json(
f"{base}/api/dcim/devices/{dev_id}/",
patch_body,
headers=nb_headers,
timeout=NB_TIMEOUT,
)
if pcode != 200:
await log_status(f"[{ts()}] nb: action_last/timestamp patch http={pcode} host={host} dev_id={dev_id}")
except Exception as e:
await log_status(f"[{ts()}] nb: action_last/timestamp patch error host={host!r} dev_id={dev_id!r} err={e!r}")
bell_prefix = "\a" * 3
else:
# action_last != action_next -> publish
effective_delay_ms = RMQ_DELAY_MS
target_exchange = RMQ_EXCHANGE_DELAYED if effective_delay_ms > 0 else RMQ_EXCHANGE_WORK
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{target_exchange}/publish"
payload_obj = {
"inscope_device": host,
"task_name": action_next_str,
}
payload_raw = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": payload_raw,
"payload_encoding": "string",
}
if effective_delay_ms > 0:
rmq_body["properties"]["headers"] = {"x-delay": int(effective_delay_ms)}
await log_status(
f"[{ts()}] ok, here i will execute\n"
f" url: {rmq_url}\n"
f" routing_key: {RMQ_ROUTING_KEY}\n"
f" payload_raw: {payload_raw}\n"
f" publish_body: {json.dumps(rmq_body, ensure_ascii=False)}"
)
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
await log_status(f"[{ts()}] rmq: publish http={code} host={host}")
else:
routed = False
try:
routed = bool((resp or {}).get("routed", False))
except Exception:
routed = False
# ---- SURGICAL FIX ----
publish_ok = True
if effective_delay_ms <= 0 and not routed:
publish_ok = False
await log_status(f"[{ts()}] rmq: publish immediate routed=false host={host}")
# ----------------------
if publish_ok:
now_epoch = int(time.time())
try:
base = NB_URL.rstrip("/")
nb_headers = {
"Accept": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
patch_body = {"custom_fields": {"action_last": action_next_str, "action_next_timestamp": str(now_epoch), "action_state": "started"}}
_, pcode = http_patch_json(
f"{base}/api/dcim/devices/{dev_id}/",
patch_body,
headers=nb_headers,
timeout=NB_TIMEOUT,
)
if pcode != 200:
await log_status(f"[{ts()}] nb: action_last/timestamp patch http={pcode} host={host} dev_id={dev_id}")
except Exception as e:
await log_status(f"[{ts()}] nb: action_last/timestamp patch error host={host!r} dev_id={dev_id!r} err={e!r}")
bell_prefix = "\a" * 3
except Exception as e:
await nb_problem(log_status, f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}")
netbox_time_ms = (time.perf_counter() - nb_start) * 1000
total_ms = (monotonic() - t_start) * 1000
async with NB_PROBLEM_LOCK:
nb_problems_snapshot = NB_PROBLEM_COUNTER
line = f"nb_problems={nb_problems_snapshot} [{ts()}] product={product} mac={mac} fw={fw}{host_suffix}{action_suffix}"
if product == "fox100":
line += f" netbox_ms={netbox_time_ms:.1f} total_ms={total_ms:.1f}"
if args.include_subject:
line += f" subject={msg.subject}"
async with print_lock:
sys.stdout.write(bell_prefix + line + "\n")
sys.stdout.flush()
if args.queue:
await nc.subscribe(args.subject, queue=args.queue, cb=message_handler)
else:
await nc.subscribe(args.subject, cb=message_handler)
await log_status(
f"[{ts()}] Listening on subject '{args.subject}' (queue={args.queue or '-'}) via {args.servers} | nb=on"
)
stop_event = asyncio.Event()
def handle_signal(*_):
asyncio.create_task(log_status(f"[{ts()}] Received stop signal, draining..."))
stop_event.set()
loop = asyncio.get_running_loop()
for s in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(s, handle_signal)
except NotImplementedError:
signal.signal(s, lambda *_: handle_signal())
await stop_event.wait()
await nc.drain()
await nc.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

View File

@@ -0,0 +1,997 @@
#!/usr/bin/env python3
"""
NATS Registration Listener (fox100 + NetBox hostname/action_next lookup + timing + problem counter)
-------------------------------------------------------------------------------------------
- One device GET (custom_fields.action_next), no duplicate fetch
- Keeps: nb_problems counter, timings, iface_id diagnostics, same formatting
- For fox100:
* If action_next is empty/absent -> print a simple stdout note and do nothing else
* If action_next present and action_last != action_next -> publish task_name=action_next
and on success set action_last=action_next and action_next_timestamp=now_epoch
* If action_next present and action_last == action_next -> publish only if
(now_epoch - action_next_timestamp) >= 600; if timestamp missing/invalid -> allow publish
and on success set action_last=action_next and action_next_timestamp=now_epoch
* If action_next present -> prepend 3x ASCII BEL to stdout line (kept behavior)
"""
import argparse
import asyncio
import json
import os
import signal
import ssl
import sys
import hashlib
import time
from time import monotonic
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any, Tuple, Set
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import base64 # for RabbitMQ Basic Auth
from pathlib import Path
import nats
# =========================
# NetBox hardcoded config
# =========================
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623" # keep as provided
NB_TIMEOUT = 6.0 # seconds per HTTP GET
# Cache for MAC -> NetBox lookup result (seconds). Keeps NetBox load down under chatty devices.
NB_LOOKUP_CACHE_TTL = float(os.environ.get("NB_LOOKUP_CACHE_TTL", "60.0"))
NB_LOOKUP_CACHE = {} # mac_norm -> (expires_monotonic, cached_tuple)
# =========================
# RabbitMQ hardcoded config
# =========================
RMQ_HOST = "10.210.12.2"
RMQ_PORT = 15672
RMQ_USER = "admin"
RMQ_PASS = "change_me"
RMQ_VHOST = "app"
RMQ_EXCHANGE_WORK = "deviceconfig" # direct exchange (immediate)
RMQ_EXCHANGE_DELAYED = "deviceconfig.delayed" # delayed exchange (x-delayed-message)
RMQ_ROUTING_KEY = "deviceconfig"
RMQ_TIMEOUT = 5.0
# ---- Human-editable delay (milliseconds). Set to 0 to disable delay.
# Example: 600000 = 10 minutes
RMQ_DELAY_MS = 15000
# Posture analyzer gate: skip re-running sot-updater-scheduler if recently run (seconds)
posture_analyzer = "sot-updater-scheduler"
sot_timeout = 300
# Product -> Tag slug mapping (kept unchanged, though not used now)
PRODUCT_TAG_SLUG = {
"fox100": "fox100-auto-upgrade-latest",
# "fox200": "fox200-auto-upgrade-latest",
}
# MACs to ignore completely when seen in registrations
IGNORE_MACS_RAW = [
"AA:BB:CC:DD:EE:FF",
"D0:6C:37:01:26:02",
"D0:6C:37:01:25:B2",
"D0:6C:37:01:25:EA",
"D0:6C:37:01:25:F2",
"D0:6C:37:01:26:02",
"D0:6C:37:01:26:1E",
"D0:6C:37:01:26:3A",
"D0:6C:37:01:26:D2",
"D0:6C:37:01:26:D6",
"D0:6C:37:01:26:E2",
"D0:6C:37:01:0C:C0",
"D0:6C:37:00:91:88",
"C4:93:00:4E:96:0C",
"D0:6C:37:01:0C:78",
"C4:93:00:51:9A:12",
"D0:6C:37:00:BD:B0",
"C4:93:00:4E:97:B0",
#"D0:6C:37:00:A9:68", #### 97227
]
# Global counter for any NetBox-related problems
NB_PROBLEM_COUNTER = 0
NB_PROBLEM_LOCK = asyncio.Lock()
# Structured event log output (JSON Lines)
EVENT_LOG_PATH = os.environ.get("EVENT_LOG_PATH", "/opt/containers/nats-registration-listener/logs/registration_events.jsonl")
# =========================
# Arg parsing
# =========================
def parse_args():
p = argparse.ArgumentParser(description="Listen to a NATS subject and print one line per device.")
p.add_argument("--servers", nargs="+", default=["nats://127.0.0.1:4222"])
p.add_argument("--subject", default="registration")
p.add_argument("--queue", default=None)
p.add_argument("--name", default="registration-listener")
p.add_argument("--creds")
p.add_argument("--user")
p.add_argument("--password")
p.add_argument("--token")
p.add_argument("--tls-ca")
p.add_argument("--tls-cert")
p.add_argument("--tls-key")
p.add_argument("--insecure", action="store_true")
p.add_argument("--include-subject", action="store_true")
return p.parse_args()
# =========================
# Helpers
# =========================
def make_ssl_context(args) -> Optional[ssl.SSLContext]:
if not any([args.tls_ca, args.tls_cert, args.tls_key]) and not any(url.startswith("tls://") for url in args.servers):
return None
ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
if args.tls_ca:
ctx.load_verify_locations(args.tls_ca)
if args.tls_cert and args.tls_key:
ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key)
if args.insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def ts() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S%z")
def ts_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def append_jsonl(path: str, obj: Dict[str, Any]):
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n")
def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if "eth0" in eths and isinstance(eths["eth0"], dict):
return eths["eth0"]
for name in sorted(eths.keys()):
if isinstance(eths[name], dict):
return eths[name]
return None
def extract_fields(obj: Dict[str, Any]):
root = obj
d = root["data"] if isinstance(root.get("data"), dict) else root
product = d.get("productName") or "-"
fw_active = d.get("firmwareVersion", {}).get("active") or "-"
mac = "-"
eths = d.get("ethernetInterfaces", {})
if isinstance(eths, dict):
chosen = pick_first_interface(eths)
if chosen and isinstance(chosen.get("macAddress"), str):
mac = chosen["macAddress"]
return product, mac, fw_active
def _parse_possible_event_epoch(value: Any) -> Optional[int]:
try:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
v = float(value)
if v > 1e12:
v = v / 1000.0
if v > 0:
return int(v)
return None
if isinstance(value, str):
s = value.strip()
if not s:
return None
if s.isdigit():
v = float(s)
if v > 1e12:
v = v / 1000.0
if v > 0:
return int(v)
return None
s2 = s.replace("Z", "+00:00")
try:
return int(datetime.fromisoformat(s2).timestamp())
except Exception:
pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try:
dt = datetime.strptime(s, fmt)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
except Exception:
pass
except Exception:
return None
return None
def extract_registration_age_s(obj: Dict[str, Any]) -> Optional[int]:
candidate_keys = {
"timestamp", "ts", "time", "event_time", "eventtime",
"event_ts", "eventtimestamp", "created_at", "createdat",
"published_at", "publishedat", "sent_at", "sentat",
"received_at", "receivedat",
}
def walk(node: Any) -> Optional[int]:
if isinstance(node, dict):
for k, v in node.items():
ks = str(k).strip().lower().replace("-", "_")
if ks in candidate_keys:
parsed = _parse_possible_event_epoch(v)
if parsed is not None:
return parsed
for v in node.values():
parsed = walk(v)
if parsed is not None:
return parsed
elif isinstance(node, list):
for item in node:
parsed = walk(item)
if parsed is not None:
return parsed
return None
epoch = walk(obj)
if epoch is None:
return None
age_s = int(time.time()) - int(epoch)
if age_s < 0:
return 0
return age_s
# =========================
# NetBox lookup (urllib)
# =========================
def normalize_mac(mac: str) -> Optional[str]:
if not mac or not isinstance(mac, str):
return None
s = mac.strip().lower().replace("-", ":")
hex_only = "".join(ch for ch in s if ch in "0123456789abcdef")
if len(hex_only) == 12:
return ":join".replace(":", "").join([":".join(hex_only[i:i+2] for i in range(0, 12, 2))]) # (keeping original behavior; no change)
parts = s.split(":")
if len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts):
return s
return None
IGNORE_MACS = {m for m in (normalize_mac(x) for x in IGNORE_MACS_RAW) if m}
def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
if params:
url = f"{url}?{urlencode(params)}"
req = Request(url, headers=headers or {}, method="GET")
try:
with urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return None, resp.status
data = resp.read()
return json.loads(data.decode("utf-8", errors="replace")), 200
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
# RabbitMQ management API POST helper (basic auth; JSON in/out)
def http_post_json(url: str, payload_obj: Dict[str, Any], user: Optional[str] = None, password: Optional[str] = None, timeout: float = RMQ_TIMEOUT):
body = json.dumps(payload_obj).encode("utf-8")
headers = {"Content-Type": "application/json"}
if user and password:
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {token}"
req = Request(url, data=body, headers=headers, method="POST")
try:
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), resp.status
except Exception:
return None, resp.status
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
# NetBox PATCH helper (JSON in/out)
def http_patch_json(url: str, payload_obj: Dict[str, Any], headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
body = json.dumps(payload_obj).encode("utf-8")
h = dict(headers or {})
h["Content-Type"] = "application/json"
req = Request(url, data=body, headers=h, method="PATCH")
try:
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), resp.status
except Exception:
return None, resp.status
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
async def nb_problem(log_status, msg: str):
"""Increment counter and log a problem line."""
global NB_PROBLEM_COUNTER
async with NB_PROBLEM_LOCK:
NB_PROBLEM_COUNTER += 1
await log_status(msg)
def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[
Optional[str], Optional[int], Optional[int], Optional[Any], Optional[Any], Optional[Any], Optional[Any], Optional[Any]
]:
"""
Resolve MAC -> (hostname, iface_id, device_id, action_next, action_last, action_next_timestamp, action_state, sot_ts)
- Logs problems for anomalies (mac not found, unassigned, wrong type, iface fetch fail).
- If device detail fetch fails, returns host/id with custom_fields as None.
"""
mac_norm = normalize_mac(mac)
if not mac_norm:
return None, None, None, None, None, None, None, None
# Step 0: short TTL cache (avoid repeated NetBox GETs for chatty devices)
if NB_LOOKUP_CACHE_TTL > 0:
nowm = monotonic()
cached = NB_LOOKUP_CACHE.get(mac_norm)
if cached:
exp, val = cached
if exp > nowm:
host, iface_id, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts = val
return host, iface_id, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts
NB_LOOKUP_CACHE.pop(mac_norm, None)
base = NB_URL.rstrip("/")
h = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
# Step 1: MAC lookup
data, code = http_get_json(
f"{base}/api/dcim/mac-addresses/",
params={"mac_address": mac_norm, "limit": "2", "fields": "assigned_object_type,assigned_object_id"},
headers=h,
)
if code == 400:
data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h)
if code != 200 or not data:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac query http={code} mac={mac_norm}"))
return None, None, None, None, None, None, None, None
results = (data or {}).get("results") or []
if not results:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac not found mac={mac_norm}"))
return None, None, None, None, None, None, None, None
rec = results[0]
aot = (rec.get("assigned_object_type") or "").strip()
aoid = rec.get("assigned_object_id")
if len(results) > 1:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: multiple mac records mac={mac_norm} iface_id={aoid if aoid is not None else '-'}"))
if not aot or aoid is None:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac unassigned mac={mac_norm}"))
return None, None, None, None, None, None, None, None
if aot != "dcim.interface":
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm} iface_id={aoid}"))
return None, aoid, None, None, None, None, None, None, None
# Step 2: Interface -> Device (shallow)
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", params={"fields": "device"}, headers=h)
if code2 == 400:
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h)
if code2 != 200 or not iface:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}"))
return None, aoid, None, None, None, None, None, None, None
dev = iface.get("device") or {}
host = dev.get("name") or dev.get("display")
dev_id = dev.get("id")
if not host or dev_id is None:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface has no device iface_id={aoid}"))
return None, aoid, None, None, None, None, None, None, None
# Step 3: Device detail (single fetch for custom_fields.*)
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", params={"fields": "custom_fields"}, headers=h)
if code3 == 400:
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", headers=h)
if code3 != 200 or not device:
# treat as "no extra info"
return host, aoid, dev_id, None, None, None, None, None
cf = device.get("custom_fields") or {}
action_next = cf.get("action_next")
action_last = cf.get("action_last")
action_next_timestamp = cf.get("action_next_timestamp")
action_state = cf.get("action_state")
sot_ts = cf.get("sot_ts")
# Step 4: populate cache (only on full success)
if NB_LOOKUP_CACHE_TTL > 0:
try:
NB_LOOKUP_CACHE[mac_norm] = (
monotonic() + NB_LOOKUP_CACHE_TTL,
(
host,
aoid,
dev_id,
action_next,
action_last,
action_next_timestamp,
action_state,
sot_ts,
),
)
except Exception:
pass
return host, aoid, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts
# =========================
# Main
# =========================
async def main():
args = parse_args()
ssl_ctx = make_ssl_context(args)
print_lock = asyncio.Lock()
event_log_lock = asyncio.Lock()
async def log_status(s: str):
async with print_lock:
print(s, file=sys.stderr, flush=True)
async def log_event(event: Dict[str, Any]):
try:
async with event_log_lock:
append_jsonl(EVENT_LOG_PATH, event)
except Exception as e:
await log_status(f"[{ts()}] event_log write error path={EVENT_LOG_PATH!r} err={e!r}")
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
recent_payloads: Dict[bytes, float] = {}
async def disconnected_cb():
await log_status(f"[{ts()}] Disconnected from NATS.")
async def reconnected_cb():
await log_status(f"[{ts()}] Reconnected to NATS.")
async def error_cb(e):
await log_status(f"[{ts()}] Error: {e!r}")
async def closed_cb():
await log_status(f"[{ts()}] Connection closed.")
nc = await nats.connect(
servers=args.servers,
name=args.name,
allow_reconnect=True,
reconnect_time_wait=2,
max_reconnect_attempts=-1,
disconnected_cb=disconnected_cb,
reconnected_cb=reconnected_cb,
error_cb=error_cb,
closed_cb=closed_cb,
user_credentials=args.creds if args.creds else None,
user=args.user,
password=args.password,
token=args.token,
tls=ssl_ctx,
)
async def message_handler(msg: nats.aio.msg.Msg):
t_start = monotonic()
payload = msg.data
event = {
"ts": ts_iso(),
"event_type": "registration_attempt",
"subject": msg.subject,
"product": "-",
"mac": "-",
"mac_norm": None,
"fw": "-",
"device_hostname": None,
"iface_id": None,
"nb_lookup_result": "not_attempted",
"ignored": False,
"ignore_reason": None,
"action_next": None,
"action_last": None,
"action_state": None,
"decision": "received",
"decision_reason": None,
"publish_attempted": False,
"publish_result": "not_attempted",
"routing_key": None,
"task_name": None,
"delay_ms": None,
"netbox_ms": None,
"total_ms": None,
"nb_problems": None,
"reg_age_s": None,
}
# strict payload dedupe
digest = hashlib.blake2b(payload, digest_size=16).digest()
nowm = monotonic()
exp = recent_payloads.get(digest)
if exp and exp > nowm:
return
recent_payloads[digest] = nowm + DEDUPE_TTL
if len(recent_payloads) > 4096:
cutoff = nowm
for k in list(recent_payloads.keys()):
if recent_payloads[k] <= cutoff:
recent_payloads.pop(k, None)
product = mac = fw = "-"
try:
text = payload.decode("utf-8", errors="replace")
obj = json.loads(text)
product, mac, fw = extract_fields(obj)
event["reg_age_s"] = extract_registration_age_s(obj)
except Exception:
pass
event["product"] = product
event["mac"] = mac
event["fw"] = fw
mac_norm = normalize_mac(mac)
event["mac_norm"] = mac_norm
if mac_norm in IGNORE_MACS:
event["ignored"] = True
event["ignore_reason"] = "mac_ignore_list"
event["decision"] = "ignored"
event["decision_reason"] = "MAC is in local ignore list"
event["total_ms"] = round((monotonic() - t_start) * 1000, 1)
async with NB_PROBLEM_LOCK:
event["nb_problems"] = NB_PROBLEM_COUNTER
await log_event(event)
async with print_lock:
print(
f"[{ts()}] ignoring registration for mac={mac}",
file=sys.stdout,
flush=True,
)
return
host_suffix = ""
action_suffix = "" # kept; not used
bell_prefix = "" # ASCII BEL when action_next present (3x)
netbox_time_ms = 0.0
if product == "fox100":
nb_start = time.perf_counter()
try:
host, iface_id, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts = nb_lookup_device_by_mac(
mac=mac, log_status=log_status
)
event["iface_id"] = iface_id
event["device_hostname"] = host
event["action_next"] = action_next
event["action_last"] = action_last
event["action_state"] = action_state
if host:
event["nb_lookup_result"] = "resolved"
host_suffix = f" device_hostname={host}"
elif iface_id:
event["nb_lookup_result"] = "iface_only"
else:
event["nb_lookup_result"] = "not_found"
if iface_id and not host:
host_suffix += f" iface_id={iface_id}"
if host:
# Gate on action_state: allow only "" or "ready"
action_state_str = ""
try:
if action_state is None:
action_state_str = ""
elif isinstance(action_state, str):
action_state_str = action_state.strip()
else:
action_state_str = str(action_state).strip()
except Exception:
action_state_str = ""
if action_state_str not in ("", "ready", "done"):
event["action_state"] = action_state_str
event["decision"] = "blocked_action_state"
event["decision_reason"] = "device is not ready because of action_state"
event["netbox_ms"] = round((time.perf_counter() - nb_start) * 1000, 1)
event["total_ms"] = round((monotonic() - t_start) * 1000, 1)
async with NB_PROBLEM_LOCK:
event["nb_problems"] = NB_PROBLEM_COUNTER
await log_event(event)
async with print_lock:
print(
f"[{ts()}] device is not ready because of action_state device_hostname={host} action_state={action_state_str}",
file=sys.stdout,
flush=True,
)
return
# Determine if action_next is present (non-empty string, or any truthy value)
has_action_next = False
action_next_str = None
try:
if isinstance(action_next, str):
action_next_str = action_next.strip()
has_action_next = len(action_next_str) > 0
else:
has_action_next = bool(action_next)
if has_action_next:
action_next_str = str(action_next)
except Exception:
has_action_next = False
action_next_str = None
if not has_action_next:
event["decision"] = "no_action"
event["decision_reason"] = "no action_next for device_hostname"
# User-requested behavior: if no action -> just shoot a message to stdout and we're ok
async with print_lock:
print(f"[{ts()}] no action_next for device_hostname={host}", file=sys.stdout, flush=True)
else:
# Compare action_last with action_next (strings)
action_last_str = None
try:
if isinstance(action_last, str):
action_last_str = action_last.strip()
elif action_last is None:
action_last_str = None
else:
action_last_str = str(action_last)
except Exception:
action_last_str = None
now_epoch = int(time.time())
# Gate: if action_next is posture_analyzer and sot_ts is recent, skip sending task
skip_due_sot = False
event["action_next"] = action_next_str
event["action_last"] = action_last_str
if action_next_str == posture_analyzer:
try:
if isinstance(sot_ts, str):
_st = sot_ts.strip()
if _st:
_dt = datetime.strptime(_st, "%d%m%y-%H%M%S").replace(tzinfo=timezone(timedelta(hours=2)))
_age = now_epoch - int(_dt.timestamp())
if _age >= 0 and _age < sot_timeout:
skip_due_sot = True
event["decision"] = "cooldown_sot"
event["decision_reason"] = f"sot_ts is recent age_s={_age}"
async with print_lock:
print(
f"[{ts()}] skip action_next because sot_ts is recent device_hostname={host} task={action_next_str} age_s={_age} sot_ts={_st}",
file=sys.stdout,
flush=True,
)
except Exception:
skip_due_sot = False
# If action_last == action_next, apply cooldown based on action_next_timestamp (600s)
if skip_due_sot:
pass
elif action_last_str == action_next_str:
allow_repeat = True
try:
if action_next_timestamp is None:
allow_repeat = True
elif isinstance(action_next_timestamp, (int, float)):
allow_repeat = (now_epoch - int(action_next_timestamp)) >= 600
elif isinstance(action_next_timestamp, str):
allow_repeat = (now_epoch - int(action_next_timestamp.strip())) >= 600
else:
allow_repeat = True
except Exception:
allow_repeat = True
if not allow_repeat:
event["decision"] = "cooldown"
event["decision_reason"] = "action_next cooldown active"
async with print_lock:
print(
f"[{ts()}] cooldown action_next for device_hostname={host} task={action_next_str}",
file=sys.stdout,
flush=True,
)
else:
# Publish task_name=action_next
effective_delay_ms = RMQ_DELAY_MS
target_exchange = RMQ_EXCHANGE_DELAYED if effective_delay_ms > 0 else RMQ_EXCHANGE_WORK
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{target_exchange}/publish"
event["publish_attempted"] = True
event["publish_result"] = "attempted"
event["routing_key"] = RMQ_ROUTING_KEY
event["task_name"] = action_next_str
payload_obj = {
"inscope_device": host,
"task_name": action_next_str,
}
payload_raw = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": payload_raw,
"payload_encoding": "string",
}
if effective_delay_ms > 0:
rmq_body["properties"]["headers"] = {"x-delay": int(effective_delay_ms)}
event["delay_ms"] = int(effective_delay_ms)
# ---- SURGICAL FIX (bell must sound during payload_raw line) ----
async with print_lock:
sys.stdout.write("\a" * 3)
sys.stdout.flush()
# -------------------------------------------------------------
await log_status(
f"[{ts()}] ok, here i will execute\n"
f" url: {rmq_url}\n"
f" routing_key: {RMQ_ROUTING_KEY}\n"
f" payload_raw: {payload_raw}\n"
f" publish_body: {json.dumps(rmq_body, ensure_ascii=False)}"
)
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
event["decision"] = "publish_failed"
event["decision_reason"] = f"rmq publish http={code}"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish http={code} device_hostname={host}")
else:
routed = False
try:
routed = bool((resp or {}).get("routed", False))
except Exception:
routed = False
# ---- SURGICAL FIX ----
# For delayed publishes (effective_delay_ms > 0), routed may be false but the message is accepted.
publish_ok = True
if effective_delay_ms <= 0 and not routed:
publish_ok = False
event["decision"] = "publish_failed"
event["decision_reason"] = "rmq immediate publish routed=false"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish immediate routed=false device_hostname={host}")
# ----------------------
if publish_ok:
event["decision"] = "scheduled"
event["decision_reason"] = "publish sent to rabbitmq"
event["publish_result"] = "sent"
# On success: set action_last and action_next_timestamp and action_state
try:
base = NB_URL.rstrip("/")
nb_headers = {
"Accept": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
patch_body = {"custom_fields": {"action_last": action_next_str, "action_next_timestamp": str(now_epoch), "action_state": "started"}}
_, pcode = http_patch_json(
f"{base}/api/dcim/devices/{dev_id}/",
patch_body,
headers=nb_headers,
timeout=NB_TIMEOUT,
)
if pcode != 200:
await log_status(f"[{ts()}] nb: action_last/timestamp patch http={pcode} device_hostname={host} dev_id={dev_id}")
except Exception as e:
await log_status(f"[{ts()}] nb: action_last/timestamp patch error device_hostname={host!r} dev_id={dev_id!r} err={e!r}")
bell_prefix = "\a" * 3
else:
# action_last != action_next -> publish
effective_delay_ms = RMQ_DELAY_MS
target_exchange = RMQ_EXCHANGE_DELAYED if effective_delay_ms > 0 else RMQ_EXCHANGE_WORK
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{target_exchange}/publish"
event["publish_attempted"] = True
event["publish_result"] = "attempted"
event["routing_key"] = RMQ_ROUTING_KEY
event["task_name"] = action_next_str
payload_obj = {
"inscope_device": host,
"task_name": action_next_str,
}
payload_raw = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": payload_raw,
"payload_encoding": "string",
}
if effective_delay_ms > 0:
rmq_body["properties"]["headers"] = {"x-delay": int(effective_delay_ms)}
event["delay_ms"] = int(effective_delay_ms)
# ---- SURGICAL FIX (bell must sound during payload_raw line) ----
async with print_lock:
sys.stdout.write("\a" * 3)
sys.stdout.flush()
# -------------------------------------------------------------
await log_status(
f"[{ts()}] ok, here i will execute\n"
f" url: {rmq_url}\n"
f" routing_key: {RMQ_ROUTING_KEY}\n"
f" payload_raw: {payload_raw}\n"
f" publish_body: {json.dumps(rmq_body, ensure_ascii=False)}"
)
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
event["decision"] = "publish_failed"
event["decision_reason"] = f"rmq publish http={code}"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish http={code} device_hostname={host}")
else:
routed = False
try:
routed = bool((resp or {}).get("routed", False))
except Exception:
routed = False
# ---- SURGICAL FIX ----
publish_ok = True
if effective_delay_ms <= 0 and not routed:
publish_ok = False
event["decision"] = "publish_failed"
event["decision_reason"] = "rmq immediate publish routed=false"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish immediate routed=false device_hostname={host}")
# ----------------------
if publish_ok:
event["decision"] = "scheduled"
event["decision_reason"] = "publish sent to rabbitmq"
event["publish_result"] = "sent"
now_epoch = int(time.time())
try:
base = NB_URL.rstrip("/")
nb_headers = {
"Accept": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
patch_body = {"custom_fields": {"action_last": action_next_str, "action_next_timestamp": str(now_epoch), "action_state": "started"}}
_, pcode = http_patch_json(
f"{base}/api/dcim/devices/{dev_id}/",
patch_body,
headers=nb_headers,
timeout=NB_TIMEOUT,
)
if pcode != 200:
await log_status(f"[{ts()}] nb: action_last/timestamp patch http={pcode} device_hostname={host} dev_id={dev_id}")
except Exception as e:
await log_status(f"[{ts()}] nb: action_last/timestamp patch error device_hostname={host!r} dev_id={dev_id!r} err={e!r}")
bell_prefix = ""
except Exception as e:
event["decision"] = "error"
event["decision_reason"] = f"nb unexpected error {e!r}"
event["nb_lookup_result"] = "error"
await nb_problem(log_status, f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}")
netbox_time_ms = (time.perf_counter() - nb_start) * 1000
total_ms = (monotonic() - t_start) * 1000
async with NB_PROBLEM_LOCK:
nb_problems_snapshot = NB_PROBLEM_COUNTER
event["netbox_ms"] = round(netbox_time_ms, 1) if product == "fox100" else None
event["total_ms"] = round(total_ms, 1)
event["nb_problems"] = nb_problems_snapshot
if event["decision"] == "received":
if product != "fox100":
event["decision"] = "unsupported_product"
event["decision_reason"] = "product is not handled by fox100 logic"
elif event["nb_lookup_result"] == "resolved":
event["decision"] = "lookup_only"
event["decision_reason"] = "registration processed without further action"
elif event["nb_lookup_result"] == "iface_only":
event["decision"] = "lookup_partial"
event["decision_reason"] = "mac resolved to interface only"
elif event["nb_lookup_result"] == "not_found":
event["decision"] = "lookup_failed"
event["decision_reason"] = "mac not found in netbox"
await log_event(event)
reg_age_s = event.get("reg_age_s")
reg_age_suffix = f" reg_age_s={reg_age_s}" if reg_age_s is not None else " reg_age_s=na"
line = f"nb_problems={nb_problems_snapshot} [{ts()}] product={product} mac={mac} fw={fw}{host_suffix}{action_suffix}{reg_age_suffix}"
if product == "fox100":
line += f" netbox_ms={netbox_time_ms:.1f} total_ms={total_ms:.1f}"
if args.include_subject:
line += f" subject={msg.subject}"
async with print_lock:
sys.stdout.write(bell_prefix + line + "\n")
sys.stdout.flush()
if args.queue:
await nc.subscribe(args.subject, queue=args.queue, cb=message_handler)
else:
await nc.subscribe(args.subject, cb=message_handler)
await log_status(
f"[{ts()}] Listening on subject '{args.subject}' (queue={args.queue or '-'}) via {args.servers} | nb=on"
)
stop_event = asyncio.Event()
def handle_signal(*_):
asyncio.create_task(log_status(f"[{ts()}] Received stop signal, draining..."))
stop_event.set()
loop = asyncio.get_running_loop()
for s in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(s, handle_signal)
except NotImplementedError:
signal.signal(s, lambda *_: handle_signal())
await stop_event.wait()
await nc.drain()
await nc.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

View File

@@ -1,10 +1,17 @@
#!/usr/bin/env python3
"""
NATS Registration Listener (fox100 + NetBox hostname/tag lookup + timing + problem counter)
NATS Registration Listener (fox100 + NetBox hostname/action_next lookup + timing + problem counter)
-------------------------------------------------------------------------------------------
- One device GET (status + tags), no duplicate fetch
- On tag match (and status=active), prepend ASCII BEL to stdout line to alert
- One device GET (custom_fields.action_next), no duplicate fetch
- Keeps: nb_problems counter, timings, iface_id diagnostics, same formatting
- For fox100:
* If action_next is empty/absent -> print a simple stdout note and do nothing else
* If action_next present and action_last != action_next -> publish task_name=action_next
and on success set action_last=action_next and action_next_timestamp=now_epoch
* If action_next present and action_last == action_next -> publish only if
(now_epoch - action_next_timestamp) >= 600; if timestamp missing/invalid -> allow publish
and on success set action_last=action_next and action_next_timestamp=now_epoch
* If action_next present -> prepend 3x ASCII BEL to stdout line (kept behavior)
"""
import argparse
@@ -17,12 +24,13 @@ import sys
import hashlib
import time
from time import monotonic
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any, Tuple, Set
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import base64 # NEW: for RabbitMQ Basic Auth
import base64 # for RabbitMQ Basic Auth
from pathlib import Path
import nats
@@ -32,30 +40,77 @@ import nats
# =========================
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623" # keep as provided
NB_TIMEOUT = 3.0 # seconds per HTTP GET
NB_TIMEOUT = 6.0 # seconds per HTTP GET
# Cache for MAC -> NetBox lookup result (seconds). Keeps NetBox load down under chatty devices.
NB_LOOKUP_CACHE_TTL = float(os.environ.get("NB_LOOKUP_CACHE_TTL", "120.0"))
NB_LOOKUP_CACHE = {} # mac_norm -> (expires_monotonic, cached_tuple)
# =========================
# RabbitMQ hardcoded config (immediate publish like rmq-ikeja-pub3.sh without delay)
# RabbitMQ hardcoded config
# =========================
RMQ_HOST = "10.210.12.2"
RMQ_PORT = 15672
RMQ_USER = "admin"
RMQ_PASS = "change_me"
RMQ_VHOST = "app"
RMQ_EXCHANGE_WORK = "deviceconfig" # direct exchange (immediate)
RMQ_EXCHANGE_WORK = "deviceconfig" # direct exchange (immediate)
RMQ_EXCHANGE_DELAYED = "deviceconfig.delayed" # delayed exchange (x-delayed-message)
RMQ_ROUTING_KEY = "deviceconfig"
RMQ_TIMEOUT = 3.0
RMQ_TIMEOUT = 5.0
# Product -> Tag slug mapping (future-proof for fox200 later)
# ---- Human-editable delay (milliseconds). Set to 0 to disable delay.
# Example: 600000 = 10 minutes
RMQ_DELAY_MS = 15000
# Posture analyzer gate: skip re-running sot-updater-scheduler if recently run (seconds)
posture_analyzer = "sot-updater-scheduler"
sot_timeout = 300
# Product -> Tag slug mapping (kept unchanged, though not used now)
PRODUCT_TAG_SLUG = {
"fox100": "fox100-auto-upgrade-latest",
# "fox200": "fox200-auto-upgrade-latest",
}
# MACs to ignore completely when seen in registrations
IGNORE_MACS_RAW = [
"AA:BB:CC:DD:EE:FF",
"D0:6C:37:01:26:02",
"D0:6C:37:01:25:B2",
"D0:6C:37:01:25:EA",
"D0:6C:37:01:25:F2",
"D0:6C:37:01:26:02",
"D0:6C:37:01:26:1E",
"D0:6C:37:01:26:3A",
"D0:6C:37:01:26:D2",
"D0:6C:37:01:26:D6",
"D0:6C:37:01:26:E2",
"D0:6C:37:01:0C:C0",
"D0:6C:37:00:91:88",
"C4:93:00:4E:96:0C",
"D0:6C:37:01:0C:78",
"C4:93:00:51:9A:12",
"D0:6C:37:00:BD:B0",
"C4:93:00:4E:97:B0",
#"D0:6C:37:00:A9:68",
"D0:6C:37:00:B0:1C",
"C4:93:00:4E:96:F0",
"D0:6C:37:00:B6:1C",
"D0:6C:37:00:EA:40",
"D0:6C:37:01:06:BC",
]
# Global counter for any NetBox-related problems
NB_PROBLEM_COUNTER = 0
NB_PROBLEM_LOCK = asyncio.Lock()
# Structured event log output (JSON Lines)
EVENT_LOG_PATH = os.environ.get("EVENT_LOG_PATH", "/opt/containers/nats-registration-listener/logs/registration_events.jsonl")
# =========================
# Arg parsing
@@ -99,6 +154,17 @@ def ts() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S%z")
def ts_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def append_jsonl(path: str, obj: Dict[str, Any]):
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n")
def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if "eth0" in eths and isinstance(eths["eth0"], dict):
return eths["eth0"]
@@ -122,6 +188,84 @@ def extract_fields(obj: Dict[str, Any]):
return product, mac, fw_active
def _parse_possible_event_epoch(value: Any) -> Optional[int]:
try:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
v = float(value)
if v > 1e12:
v = v / 1000.0
if v > 0:
return int(v)
return None
if isinstance(value, str):
s = value.strip()
if not s:
return None
if s.isdigit():
v = float(s)
if v > 1e12:
v = v / 1000.0
if v > 0:
return int(v)
return None
s2 = s.replace("Z", "+00:00")
try:
return int(datetime.fromisoformat(s2).timestamp())
except Exception:
pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try:
dt = datetime.strptime(s, fmt)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
except Exception:
pass
except Exception:
return None
return None
def extract_registration_age_s(obj: Dict[str, Any]) -> Optional[int]:
candidate_keys = {
"timestamp", "ts", "time", "event_time", "eventtime",
"event_ts", "eventtimestamp", "created_at", "createdat",
"published_at", "publishedat", "sent_at", "sentat",
"received_at", "receivedat",
}
def walk(node: Any) -> Optional[int]:
if isinstance(node, dict):
for k, v in node.items():
ks = str(k).strip().lower().replace("-", "_")
if ks in candidate_keys:
parsed = _parse_possible_event_epoch(v)
if parsed is not None:
return parsed
for v in node.values():
parsed = walk(v)
if parsed is not None:
return parsed
elif isinstance(node, list):
for item in node:
parsed = walk(item)
if parsed is not None:
return parsed
return None
epoch = walk(obj)
if epoch is None:
return None
age_s = int(time.time()) - int(epoch)
if age_s < 0:
return 0
return age_s
# =========================
# NetBox lookup (urllib)
# =========================
@@ -138,6 +282,9 @@ def normalize_mac(mac: str) -> Optional[str]:
return None
IGNORE_MACS = {m for m in (normalize_mac(x) for x in IGNORE_MACS_RAW) if m}
def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
if params:
url = f"{url}?{urlencode(params)}"
@@ -156,7 +303,7 @@ def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Op
return None, 597
# NEW: RabbitMQ management API POST helper (basic auth; JSON in/out)
# RabbitMQ management API POST helper (basic auth; JSON in/out)
def http_post_json(url: str, payload_obj: Dict[str, Any], user: Optional[str] = None, password: Optional[str] = None, timeout: float = RMQ_TIMEOUT):
body = json.dumps(payload_obj).encode("utf-8")
headers = {"Content-Type": "application/json"}
@@ -179,6 +326,27 @@ def http_post_json(url: str, payload_obj: Dict[str, Any], user: Optional[str] =
return None, 597
# NetBox PATCH helper (JSON in/out)
def http_patch_json(url: str, payload_obj: Dict[str, Any], headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
body = json.dumps(payload_obj).encode("utf-8")
h = dict(headers or {})
h["Content-Type"] = "application/json"
req = Request(url, data=body, headers=h, method="PATCH")
try:
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), resp.status
except Exception:
return None, resp.status
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
async def nb_problem(log_status, msg: str):
"""Increment counter and log a problem line."""
global NB_PROBLEM_COUNTER
@@ -187,15 +355,28 @@ async def nb_problem(log_status, msg: str):
await log_status(msg)
def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[Optional[str], Optional[int], Optional[int], Optional[str], Optional[Set[str]]]:
def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[
Optional[str], Optional[int], Optional[int], Optional[Any], Optional[Any], Optional[Any], Optional[Any], Optional[Any]
]:
"""
Resolve MAC -> (hostname, iface_id, device_id, device_status_value, tag_slugs_set)
Resolve MAC -> (hostname, iface_id, device_id, action_next, action_last, action_next_timestamp, action_state, sot_ts)
- Logs problems for anomalies (mac not found, unassigned, wrong type, iface fetch fail).
- If device detail fetch fails, returns host/id with status/tags as None (treated as "no tag info").
- If device detail fetch fails, returns host/id with custom_fields as None.
"""
mac_norm = normalize_mac(mac)
if not mac_norm:
return None, None, None, None, None
return None, None, None, None, None, None, None, None
# Step 0: short TTL cache (avoid repeated NetBox GETs for chatty devices)
if NB_LOOKUP_CACHE_TTL > 0:
nowm = monotonic()
cached = NB_LOOKUP_CACHE.get(mac_norm)
if cached:
exp, val = cached
if exp > nowm:
host, iface_id, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts = val
return host, iface_id, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts
NB_LOOKUP_CACHE.pop(mac_norm, None)
base = NB_URL.rstrip("/")
h = {
@@ -205,15 +386,21 @@ def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[Optional[str], Option
}
# Step 1: MAC lookup
data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h)
data, code = http_get_json(
f"{base}/api/dcim/mac-addresses/",
params={"mac_address": mac_norm, "limit": "2", "fields": "assigned_object_type,assigned_object_id"},
headers=h,
)
if code == 400:
data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h)
if code != 200 or not data:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac query http={code} mac={mac_norm}"))
return None, None, None, None, None
return None, None, None, None, None, None, None, None
results = (data or {}).get("results") or []
if not results:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac not found mac={mac_norm}"))
return None, None, None, None, None
return None, None, None, None, None, None, None, None
rec = results[0]
aot = (rec.get("assigned_object_type") or "").strip()
@@ -224,39 +411,61 @@ def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[Optional[str], Option
if not aot or aoid is None:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac unassigned mac={mac_norm}"))
return None, None, None, None, None
return None, None, None, None, None, None, None, None
if aot != "dcim.interface":
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm} iface_id={aoid}"))
return None, aoid, None, None, None
return None, aoid, None, None, None, None, None, None, None
# Step 2: Interface -> Device (shallow)
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h)
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", params={"fields": "device"}, headers=h)
if code2 == 400:
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h)
if code2 != 200 or not iface:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}"))
return None, aoid, None, None, None
return None, aoid, None, None, None, None, None, None, None
dev = iface.get("device") or {}
host = dev.get("name") or dev.get("display")
dev_id = dev.get("id")
if not host or dev_id is None:
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface has no device iface_id={aoid}"))
return None, aoid, None, None, None
return None, aoid, None, None, None, None, None, None, None
# Step 3: Device detail (single fetch for BOTH status and tags)
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", headers=h)
# Step 3: Device detail (single fetch for custom_fields.*)
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", params={"fields": "custom_fields"}, headers=h)
if code3 == 400:
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", headers=h)
if code3 != 200 or not device:
# treat as "no tag info" (no problem increment)
return host, aoid, dev_id, None, None
# treat as "no extra info"
return host, aoid, dev_id, None, None, None, None, None
status_val = ((device.get("status") or {}).get("value")) or None
tags = device.get("tags") or []
tag_slugs = set()
for t in tags:
slug = t.get("slug")
if isinstance(slug, str):
tag_slugs.add(slug)
cf = device.get("custom_fields") or {}
action_next = cf.get("action_next")
action_last = cf.get("action_last")
action_next_timestamp = cf.get("action_next_timestamp")
action_state = cf.get("action_state")
sot_ts = cf.get("sot_ts")
return host, aoid, dev_id, (status_val if isinstance(status_val, str) else None), tag_slugs
# Step 4: populate cache (only on full success)
if NB_LOOKUP_CACHE_TTL > 0:
try:
NB_LOOKUP_CACHE[mac_norm] = (
monotonic() + NB_LOOKUP_CACHE_TTL,
(
host,
aoid,
dev_id,
action_next,
action_last,
action_next_timestamp,
action_state,
sot_ts,
),
)
except Exception:
pass
return host, aoid, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts
# =========================
@@ -267,11 +476,19 @@ async def main():
ssl_ctx = make_ssl_context(args)
print_lock = asyncio.Lock()
event_log_lock = asyncio.Lock()
async def log_status(s: str):
async with print_lock:
print(s, file=sys.stderr, flush=True)
async def log_event(event: Dict[str, Any]):
try:
async with event_log_lock:
append_jsonl(EVENT_LOG_PATH, event)
except Exception as e:
await log_status(f"[{ts()}] event_log write error path={EVENT_LOG_PATH!r} err={e!r}")
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
recent_payloads: Dict[bytes, float] = {}
@@ -304,9 +521,40 @@ async def main():
tls=ssl_ctx,
)
async def message_handler(msg: nats.aio.msg.Msg):
async def message_handler(msg: nats.aio.msg.Msg, queued_monotonic: Optional[float] = None, queued_iso: Optional[str] = None):
t_start = monotonic()
local_queue_depth = processing_queue.qsize()
payload = msg.data
event = {
"ts": ts_iso(),
"event_type": "registration_attempt",
"subject": msg.subject,
"product": "-",
"mac": "-",
"mac_norm": None,
"fw": "-",
"device_hostname": None,
"iface_id": None,
"nb_lookup_result": "not_attempted",
"ignored": False,
"ignore_reason": None,
"action_next": None,
"action_last": None,
"action_state": None,
"decision": "received",
"decision_reason": None,
"publish_attempted": False,
"publish_result": "not_attempted",
"routing_key": None,
"task_name": None,
"delay_ms": None,
"netbox_ms": None,
"total_ms": None,
"nb_problems": None,
"reg_age_s": None,
"local_queue_depth": None,
}
event["local_queue_depth"] = local_queue_depth
# strict payload dedupe
digest = hashlib.blake2b(payload, digest_size=16).digest()
@@ -326,61 +574,363 @@ async def main():
text = payload.decode("utf-8", errors="replace")
obj = json.loads(text)
product, mac, fw = extract_fields(obj)
event["reg_age_s"] = extract_registration_age_s(obj)
except Exception:
pass
event["product"] = product
event["mac"] = mac
event["fw"] = fw
mac_norm = normalize_mac(mac)
event["mac_norm"] = mac_norm
if mac_norm in IGNORE_MACS:
event["ignored"] = True
event["ignore_reason"] = "mac_ignore_list"
event["decision"] = "ignored"
event["decision_reason"] = "MAC is in local ignore list"
event["total_ms"] = round((monotonic() - t_start) * 1000, 1)
async with NB_PROBLEM_LOCK:
event["nb_problems"] = NB_PROBLEM_COUNTER
await log_event(event)
async with print_lock:
print(
f"[{ts()}] ignoring registration for mac={mac}",
file=sys.stdout,
flush=True,
)
return
host_suffix = ""
action_suffix = ""
bell_prefix = "" # ASCII BEL when we have a tag match
action_suffix = "" # kept; not used
bell_prefix = "" # ASCII BEL when action_next present (3x)
netbox_time_ms = 0.0
desired_slug = PRODUCT_TAG_SLUG.get(product)
if product == "fox100":
nb_start = time.perf_counter()
try:
host, iface_id, dev_id, status_val, tag_slugs = nb_lookup_device_by_mac(mac=mac, log_status=log_status)
host, iface_id, dev_id, action_next, action_last, action_next_timestamp, action_state, sot_ts = nb_lookup_device_by_mac(
mac=mac, log_status=log_status
)
event["iface_id"] = iface_id
event["device_hostname"] = host
event["action_next"] = action_next
event["action_last"] = action_last
event["action_state"] = action_state
if host:
host_suffix = f" host={host}"
event["nb_lookup_result"] = "resolved"
host_suffix = f" device_hostname={host}"
elif iface_id:
event["nb_lookup_result"] = "iface_only"
else:
event["nb_lookup_result"] = "not_found"
if iface_id and not host:
host_suffix += f" iface_id={iface_id}"
# Tag-based action (unchanged)
if dev_id is not None and status_val == "active" and isinstance(desired_slug, str) and isinstance(tag_slugs, set):
if desired_slug in tag_slugs:
bell_prefix = "\a" * 5
action_suffix = f" action=ok, i'm ready to schedule this device {host if host else dev_id} upgrade"
# NEW: Publish immediate RMQ message like rmq-ikeja-pub3.sh (no delay)
if host:
# Gate on action_state: allow only "" or "ready"
action_state_str = ""
try:
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{RMQ_EXCHANGE_WORK}/publish"
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": json.dumps({
"inscope_device": host,
"task_name": "sot-updater-upgradecmd",
}),
"payload_encoding": "string",
}
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
await log_status(f"[{ts()}] rmq: publish immediate http={code} host={host}")
if action_state is None:
action_state_str = ""
elif isinstance(action_state, str):
action_state_str = action_state.strip()
else:
# if response JSON has 'routed' false, note it (rmq-ikeja-pub3.sh warns in that case)
routed = False
action_state_str = str(action_state).strip()
except Exception:
action_state_str = ""
if action_state_str not in ("", "ready", "done"):
event["action_state"] = action_state_str
event["decision"] = "blocked_action_state"
event["decision_reason"] = "device is not ready because of action_state"
event["netbox_ms"] = round((time.perf_counter() - nb_start) * 1000, 1)
event["total_ms"] = round((monotonic() - t_start) * 1000, 1)
async with NB_PROBLEM_LOCK:
event["nb_problems"] = NB_PROBLEM_COUNTER
await log_event(event)
async with print_lock:
print(
f"[{ts()}] device is not ready because of action_state device_hostname={host} action_state={action_state_str}",
file=sys.stdout,
flush=True,
)
return
# Determine if action_next is present (non-empty string, or any truthy value)
has_action_next = False
action_next_str = None
try:
if isinstance(action_next, str):
action_next_str = action_next.strip()
has_action_next = len(action_next_str) > 0
else:
has_action_next = bool(action_next)
if has_action_next:
action_next_str = str(action_next)
except Exception:
has_action_next = False
action_next_str = None
if not has_action_next:
event["decision"] = "no_action"
event["decision_reason"] = "no action_next for device_hostname"
# User-requested behavior: if no action -> just shoot a message to stdout and we're ok
async with print_lock:
print(f"[{ts()}] no action_next for device_hostname={host}", file=sys.stdout, flush=True)
else:
# Compare action_last with action_next (strings)
action_last_str = None
try:
if isinstance(action_last, str):
action_last_str = action_last.strip()
elif action_last is None:
action_last_str = None
else:
action_last_str = str(action_last)
except Exception:
action_last_str = None
now_epoch = int(time.time())
# Gate: if action_next is posture_analyzer and sot_ts is recent, skip sending task
skip_due_sot = False
event["action_next"] = action_next_str
event["action_last"] = action_last_str
if action_next_str == posture_analyzer:
try:
routed = bool((resp or {}).get("routed", False))
if isinstance(sot_ts, str):
_st = sot_ts.strip()
if _st:
_dt = datetime.strptime(_st, "%d%m%y-%H%M%S").replace(tzinfo=timezone(timedelta(hours=2)))
_age = now_epoch - int(_dt.timestamp())
if _age >= 0 and _age < sot_timeout:
skip_due_sot = True
event["decision"] = "cooldown_sot"
event["decision_reason"] = f"sot_ts is recent age_s={_age}"
async with print_lock:
print(
f"[{ts()}] skip action_next because sot_ts is recent device_hostname={host} task={action_next_str} age_s={_age} sot_ts={_st}",
file=sys.stdout,
flush=True,
)
except Exception:
skip_due_sot = False
# If action_last == action_next, apply cooldown based on action_next_timestamp (600s)
if skip_due_sot:
pass
elif action_last_str == action_next_str:
allow_repeat = True
try:
if action_next_timestamp is None:
allow_repeat = True
elif isinstance(action_next_timestamp, (int, float)):
allow_repeat = (now_epoch - int(action_next_timestamp)) >= 600
elif isinstance(action_next_timestamp, str):
allow_repeat = (now_epoch - int(action_next_timestamp.strip())) >= 600
else:
allow_repeat = True
except Exception:
allow_repeat = True
if not allow_repeat:
event["decision"] = "cooldown"
event["decision_reason"] = "action_next cooldown active"
async with print_lock:
print(
f"[{ts()}] cooldown action_next for device_hostname={host} task={action_next_str}",
file=sys.stdout,
flush=True,
)
else:
# Publish task_name=action_next
effective_delay_ms = RMQ_DELAY_MS
target_exchange = RMQ_EXCHANGE_DELAYED if effective_delay_ms > 0 else RMQ_EXCHANGE_WORK
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{target_exchange}/publish"
event["publish_attempted"] = True
event["publish_result"] = "attempted"
event["routing_key"] = RMQ_ROUTING_KEY
event["task_name"] = action_next_str
payload_obj = {
"inscope_device": host,
"task_name": action_next_str,
}
payload_raw = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": payload_raw,
"payload_encoding": "string",
}
if effective_delay_ms > 0:
rmq_body["properties"]["headers"] = {"x-delay": int(effective_delay_ms)}
event["delay_ms"] = int(effective_delay_ms)
# ---- SURGICAL FIX (bell must sound during payload_raw line) ----
async with print_lock:
sys.stdout.write("\a" * 3)
sys.stdout.flush()
# -------------------------------------------------------------
await log_status(
f"[{ts()}] ok, here i will execute\n"
f" url: {rmq_url}\n"
f" routing_key: {RMQ_ROUTING_KEY}\n"
f" payload_raw: {payload_raw}\n"
f" publish_body: {json.dumps(rmq_body, ensure_ascii=False)}"
)
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
event["decision"] = "publish_failed"
event["decision_reason"] = f"rmq publish http={code}"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish http={code} device_hostname={host}")
else:
routed = False
try:
routed = bool((resp or {}).get("routed", False))
except Exception:
routed = False
# ---- SURGICAL FIX ----
# For delayed publishes (effective_delay_ms > 0), routed may be false but the message is accepted.
publish_ok = True
if effective_delay_ms <= 0 and not routed:
publish_ok = False
event["decision"] = "publish_failed"
event["decision_reason"] = "rmq immediate publish routed=false"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish immediate routed=false device_hostname={host}")
# ----------------------
if publish_ok:
event["decision"] = "scheduled"
event["decision_reason"] = "publish sent to rabbitmq"
event["publish_result"] = "sent"
# On success: set action_last and action_next_timestamp and action_state
try:
base = NB_URL.rstrip("/")
nb_headers = {
"Accept": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
patch_body = {"custom_fields": {"action_last": action_next_str, "action_next_timestamp": str(now_epoch), "action_state": "started"}}
_, pcode = http_patch_json(
f"{base}/api/dcim/devices/{dev_id}/",
patch_body,
headers=nb_headers,
timeout=NB_TIMEOUT,
)
if pcode != 200:
await log_status(f"[{ts()}] nb: action_last/timestamp patch http={pcode} device_hostname={host} dev_id={dev_id}")
except Exception as e:
await log_status(f"[{ts()}] nb: action_last/timestamp patch error device_hostname={host!r} dev_id={dev_id!r} err={e!r}")
bell_prefix = "\a" * 3
else:
# action_last != action_next -> publish
effective_delay_ms = RMQ_DELAY_MS
target_exchange = RMQ_EXCHANGE_DELAYED if effective_delay_ms > 0 else RMQ_EXCHANGE_WORK
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{target_exchange}/publish"
event["publish_attempted"] = True
event["publish_result"] = "attempted"
event["routing_key"] = RMQ_ROUTING_KEY
event["task_name"] = action_next_str
payload_obj = {
"inscope_device": host,
"task_name": action_next_str,
}
payload_raw = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
rmq_body = {
"properties": {
"content_type": "application/json"
},
"routing_key": RMQ_ROUTING_KEY,
"payload": payload_raw,
"payload_encoding": "string",
}
if effective_delay_ms > 0:
rmq_body["properties"]["headers"] = {"x-delay": int(effective_delay_ms)}
event["delay_ms"] = int(effective_delay_ms)
# ---- SURGICAL FIX (bell must sound during payload_raw line) ----
async with print_lock:
sys.stdout.write("\a" * 3)
sys.stdout.flush()
# -------------------------------------------------------------
await log_status(
f"[{ts()}] ok, here i will execute\n"
f" url: {rmq_url}\n"
f" routing_key: {RMQ_ROUTING_KEY}\n"
f" payload_raw: {payload_raw}\n"
f" publish_body: {json.dumps(rmq_body, ensure_ascii=False)}"
)
resp, code = http_post_json(rmq_url, rmq_body, user=RMQ_USER, password=RMQ_PASS, timeout=RMQ_TIMEOUT)
if code != 200:
event["decision"] = "publish_failed"
event["decision_reason"] = f"rmq publish http={code}"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish http={code} device_hostname={host}")
else:
routed = False
if not routed:
await log_status(f"[{ts()}] rmq: publish immediate routed=false host={host}")
except Exception as e:
await log_status(f"[{ts()}] rmq: unexpected error host={host!r} err={e!r}")
try:
routed = bool((resp or {}).get("routed", False))
except Exception:
routed = False
# ---- SURGICAL FIX ----
publish_ok = True
if effective_delay_ms <= 0 and not routed:
publish_ok = False
event["decision"] = "publish_failed"
event["decision_reason"] = "rmq immediate publish routed=false"
event["publish_result"] = "failed"
await log_status(f"[{ts()}] rmq: publish immediate routed=false device_hostname={host}")
# ----------------------
if publish_ok:
event["decision"] = "scheduled"
event["decision_reason"] = "publish sent to rabbitmq"
event["publish_result"] = "sent"
now_epoch = int(time.time())
try:
base = NB_URL.rstrip("/")
nb_headers = {
"Accept": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
patch_body = {"custom_fields": {"action_last": action_next_str, "action_next_timestamp": str(now_epoch), "action_state": "started"}}
_, pcode = http_patch_json(
f"{base}/api/dcim/devices/{dev_id}/",
patch_body,
headers=nb_headers,
timeout=NB_TIMEOUT,
)
if pcode != 200:
await log_status(f"[{ts()}] nb: action_last/timestamp patch http={pcode} device_hostname={host} dev_id={dev_id}")
except Exception as e:
await log_status(f"[{ts()}] nb: action_last/timestamp patch error device_hostname={host!r} dev_id={dev_id!r} err={e!r}")
bell_prefix = ""
except Exception as e:
event["decision"] = "error"
event["decision_reason"] = f"nb unexpected error {e!r}"
event["nb_lookup_result"] = "error"
await nb_problem(log_status, f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}")
netbox_time_ms = (time.perf_counter() - nb_start) * 1000
@@ -389,21 +939,59 @@ async def main():
async with NB_PROBLEM_LOCK:
nb_problems_snapshot = NB_PROBLEM_COUNTER
line = f"nb_problems={nb_problems_snapshot} [{ts()}] product={product} mac={mac} fw={fw}{host_suffix}{action_suffix}"
event["netbox_ms"] = round(netbox_time_ms, 1) if product == "fox100" else None
event["total_ms"] = round(total_ms, 1)
event["nb_problems"] = nb_problems_snapshot
if event["decision"] == "received":
if product != "fox100":
event["decision"] = "unsupported_product"
event["decision_reason"] = "product is not handled by fox100 logic"
elif event["nb_lookup_result"] == "resolved":
event["decision"] = "lookup_only"
event["decision_reason"] = "registration processed without further action"
elif event["nb_lookup_result"] == "iface_only":
event["decision"] = "lookup_partial"
event["decision_reason"] = "mac resolved to interface only"
elif event["nb_lookup_result"] == "not_found":
event["decision"] = "lookup_failed"
event["decision_reason"] = "mac not found in netbox"
await log_event(event)
reg_age_s = event.get("reg_age_s")
reg_age_suffix = f" reg_age_s={reg_age_s}" if reg_age_s is not None else " reg_age_s=na"
queue_suffix = f" local_queue={local_queue_depth}"
line = f"nb_problems={nb_problems_snapshot} [{ts()}] product={product} mac={mac} fw={fw}{host_suffix}{action_suffix}{reg_age_suffix}{queue_suffix}"
if product == "fox100":
line += f" netbox_ms={netbox_time_ms:.1f} total_ms={total_ms:.1f}"
if args.include_subject:
line += f" subject={msg.subject}"
async with print_lock:
# Prepend BEL only when we had a tag match
sys.stdout.write(bell_prefix + line + "\n")
sys.stdout.flush()
processing_queue: asyncio.Queue = asyncio.Queue()
async def enqueue_message(msg: nats.aio.msg.Msg):
await processing_queue.put((msg, monotonic(), ts_iso()))
async def processing_worker():
while True:
item = await processing_queue.get()
try:
if item is None:
return
qmsg, queued_monotonic, queued_iso = item
await message_handler(qmsg, queued_monotonic=queued_monotonic, queued_iso=queued_iso)
finally:
processing_queue.task_done()
worker_task = asyncio.create_task(processing_worker())
if args.queue:
await nc.subscribe(args.subject, queue=args.queue, cb=message_handler)
await nc.subscribe(args.subject, queue=args.queue, cb=enqueue_message)
else:
await nc.subscribe(args.subject, cb=message_handler)
await nc.subscribe(args.subject, cb=enqueue_message)
await log_status(
f"[{ts()}] Listening on subject '{args.subject}' (queue={args.queue or '-'}) via {args.servers} | nb=on"
@@ -424,6 +1012,9 @@ async def main():
await stop_event.wait()
await nc.drain()
await processing_queue.join()
await processing_queue.put(None)
await worker_task
await nc.close()
@@ -431,4 +1022,4 @@ if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
pass