Compare commits
85 Commits
main
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 139e675794 | |||
| 8c59d94aff | |||
| 04ab48c314 | |||
| b1f6de5c65 | |||
| 773a74c27c | |||
| 98d3e57737 | |||
| c41ae67ee8 | |||
| 81964183e8 | |||
| 7289430fc3 | |||
| 651e4dc1bc | |||
| 7e624e48f8 | |||
| a60b8c7012 | |||
| 5f29ea2e1d | |||
| 7540952328 | |||
| 26908890be | |||
| 1571f70ddd | |||
| 1d5e59810f | |||
| c223ae7043 | |||
| 94cf4cabe3 | |||
| ae501fbd3a | |||
| 6af07445f0 | |||
| 4e23aa5e8d | |||
| a79472bacc | |||
| fc1f2726a0 | |||
| 5e06d58a10 | |||
| 674f7c84f9 | |||
| 26450a767b | |||
| 25620eff65 | |||
| 6a4df2db0f | |||
| c65a40884c | |||
| 801b91b85f | |||
| dfaf9d19b4 | |||
| b11f00c827 | |||
| 2a74c52b7b | |||
| dc9c6e721c | |||
| 9c6cbbdf5b | |||
| f6722b491c | |||
| 5fd5f85641 | |||
| b30cb221e8 | |||
| 2d966d19e7 | |||
| 58aa97f10c | |||
| 938b76be02 | |||
| 3dae12ad23 | |||
| 25cebe140b | |||
| 428627717b | |||
| 22f6ba7727 | |||
| 3747888a5c | |||
| 1f3827cb6e | |||
| 0930e7ea97 | |||
| 8b45630ee4 | |||
| 9eb245a517 | |||
| eaa8117f29 | |||
| 6d19ad2814 | |||
| b3eee11104 | |||
| e1e36f82f9 | |||
| 584348253c | |||
| fc476be2cc | |||
| a963a9e0f7 | |||
| 7361a85f02 | |||
| 2fd1f920c0 | |||
| 9f40495e96 | |||
| c48a80948b | |||
| 8c7a9cc6c9 | |||
| 4f184cfdf6 | |||
| 49eb9fd262 | |||
| c70fa140b8 | |||
| 94e55887a7 | |||
| c268337621 | |||
| 7cb47d2188 | |||
| d3c3a32e7c | |||
| ef4820661c | |||
| 80d23b669a | |||
| 0859450ac4 | |||
| 17606178ce | |||
| 4578d443a2 | |||
| 0fe60b95f8 | |||
| 99bc927ea2 | |||
| 07c1a9bb22 | |||
| 7004c0b9bb | |||
| 5c03bce9cc | |||
| 5d19eb0f26 | |||
| 46ea4359ed | |||
|
|
c2a8d3fc44 | ||
| e48e2f28fc | |||
| f2600d2bb6 |
1
.env
1
.env
@@ -56,3 +56,4 @@ RrJEcyns1Qnh7/5+ZOY/UhU4h++uBuUVacH4DmOJgbxae/OXNAMcpbvYM2SZ6KfR
|
|||||||
|
|
||||||
# Only disable hostname verification if absolutely necessary:
|
# Only disable hostname verification if absolutely necessary:
|
||||||
TLS_INSECURE=1
|
TLS_INSECURE=1
|
||||||
|
NB_LOOKUP_CACHE_TTL=120
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
31
docker-compose-old.yml
Normal file
31
docker-compose-old.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
nats-registration-listener:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: nats-registration-listener:latest
|
||||||
|
|
||||||
|
# Share host network so 'localhost:4222' talks to NATS on the host (if that's your setup)
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
working_dir: /opt/containers/nats-registration-listener/app
|
||||||
|
# entrypoint: ["/usr/bin/tini", "--", "/bin/bash", "-lc", "/usr/local/bin/nats-registration-entrypoint"]
|
||||||
|
entrypoint: ["/usr/bin/tini","--","/bin/bash","-lc","/usr/local/bin/nats-registration-entrypoint"]
|
||||||
|
# command: ["/opt/containers/nats-registration-listener/app/bin/nats-registration-listener.sh"]
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
|
||||||
|
# One .env to rule them all
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./data:/opt/containers/nats-registration-listener/data:rw
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "/usr/local/bin/nats-registration-healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
@@ -1,31 +1,50 @@
|
|||||||
services:
|
services:
|
||||||
nats-registration-listener:
|
nats-registration-listener-even:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: nats-registration-listener:latest
|
image: nats-registration-listener:latest
|
||||||
|
|
||||||
# Share host network so 'localhost:4222' talks to NATS on the host (if that's your setup)
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|
||||||
working_dir: /opt/containers/nats-registration-listener/app
|
working_dir: /opt/containers/nats-registration-listener/app
|
||||||
# entrypoint: ["/usr/bin/tini", "--", "/bin/bash", "-lc", "/usr/local/bin/nats-registration-entrypoint"]
|
|
||||||
entrypoint: ["/usr/bin/tini","--","/bin/bash","-lc","/usr/local/bin/nats-registration-entrypoint"]
|
entrypoint: ["/usr/bin/tini","--","/bin/bash","-lc","/usr/local/bin/nats-registration-entrypoint"]
|
||||||
# command: ["/opt/containers/nats-registration-listener/app/bin/nats-registration-listener.sh"]
|
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
tty: true
|
tty: true
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
|
environment:
|
||||||
# One .env to rule them all
|
EVENT_LOG_PATH: "/opt/containers/nats-registration-listener/logs/registration_events_even.jsonl"
|
||||||
|
EVENODD: "even"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/opt/containers/nats-registration-listener/data:rw
|
- ./data:/opt/containers/nats-registration-listener/data:rw
|
||||||
|
- ./logs:/opt/containers/nats-registration-listener/logs:rw
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "/usr/local/bin/nats-registration-healthcheck"]
|
test: ["CMD-SHELL", "/usr/local/bin/nats-registration-healthcheck"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
nats-registration-listener-odd:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: nats-registration-listener:latest
|
||||||
|
network_mode: host
|
||||||
|
working_dir: /opt/containers/nats-registration-listener/app
|
||||||
|
entrypoint: ["/usr/bin/tini","--","/bin/bash","-lc","/usr/local/bin/nats-registration-entrypoint"]
|
||||||
|
restart: unless-stopped
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
environment:
|
||||||
|
EVENT_LOG_PATH: "/opt/containers/nats-registration-listener/logs/registration_events_odd.jsonl"
|
||||||
|
EVENODD: "odd"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/opt/containers/nats-registration-listener/data:rw
|
||||||
|
- ./logs:/opt/containers/nats-registration-listener/logs:rw
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "/usr/local/bin/nats-registration-healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
711
files/nats_registration_listener-0225.py
Normal file
711
files/nats_registration_listener-0225.py
Normal 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
|
||||||
997
files/nats_registration_listener-0323.py
Normal file
997
files/nats_registration_listener-0323.py
Normal 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
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
- One device GET (custom_fields.action_next), no duplicate fetch
|
||||||
- On tag match (and status=active), prepend ASCII BEL to stdout line to alert
|
|
||||||
- Keeps: nb_problems counter, timings, iface_id diagnostics, same formatting
|
- 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 argparse
|
||||||
@@ -17,12 +24,13 @@ import sys
|
|||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional, Dict, Any, Tuple, Set
|
from typing import Optional, Dict, Any, Tuple, Set
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from urllib.error import URLError, HTTPError
|
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
|
import nats
|
||||||
|
|
||||||
@@ -32,30 +40,66 @@ import nats
|
|||||||
# =========================
|
# =========================
|
||||||
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
|
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
|
||||||
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623" # keep as provided
|
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_HOST = "10.210.12.2"
|
||||||
RMQ_PORT = 15672
|
RMQ_PORT = 15672
|
||||||
RMQ_USER = "admin"
|
RMQ_USER = "admin"
|
||||||
RMQ_PASS = "change_me"
|
RMQ_PASS = "change_me"
|
||||||
RMQ_VHOST = "app"
|
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_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 = 300000
|
||||||
|
|
||||||
|
# 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 = {
|
PRODUCT_TAG_SLUG = {
|
||||||
"fox100": "fox100-auto-upgrade-latest",
|
"fox100": "fox100-auto-upgrade-latest",
|
||||||
# "fox200": "fox200-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:4A:25",
|
||||||
|
"C4:93:00:4E:9E:C0", # 84823
|
||||||
|
"C4:93:00:4E:97:B0", # 6667
|
||||||
|
"D0:6C:37:01:26:D2", #NOT known for webapps
|
||||||
|
"D0:6C:37:01:0C:C0", #NOT known for webapps
|
||||||
|
"D0:6C:37:01:0C:7C", #NOT known for webapps
|
||||||
|
"d0:6c:37:00:b0:1c", # 101200 flaps a lot
|
||||||
|
"d0:6c:37:01:06:bc", #ikeja93929
|
||||||
|
]
|
||||||
|
|
||||||
# Global counter for any NetBox-related problems
|
# Global counter for any NetBox-related problems
|
||||||
NB_PROBLEM_COUNTER = 0
|
NB_PROBLEM_COUNTER = 0
|
||||||
NB_PROBLEM_LOCK = asyncio.Lock()
|
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")
|
||||||
|
|
||||||
|
EVENODD = (os.environ.get("EVENODD", "") or "").strip().lower()
|
||||||
|
if EVENODD not in ("", "even", "odd"):
|
||||||
|
raise SystemExit("ERROR: EVENODD must be unset, 'even', or 'odd'")
|
||||||
|
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# Arg parsing
|
# Arg parsing
|
||||||
@@ -99,6 +143,17 @@ def ts() -> str:
|
|||||||
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S%z")
|
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]]:
|
def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
if "eth0" in eths and isinstance(eths["eth0"], dict):
|
if "eth0" in eths and isinstance(eths["eth0"], dict):
|
||||||
return eths["eth0"]
|
return eths["eth0"]
|
||||||
@@ -122,6 +177,84 @@ def extract_fields(obj: Dict[str, Any]):
|
|||||||
return product, mac, fw_active
|
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)
|
# NetBox lookup (urllib)
|
||||||
# =========================
|
# =========================
|
||||||
@@ -138,6 +271,21 @@ def normalize_mac(mac: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mac_belongs_to_this_shard(mac_norm: Optional[str]) -> bool:
|
||||||
|
if EVENODD == "":
|
||||||
|
return True
|
||||||
|
if not mac_norm:
|
||||||
|
return True
|
||||||
|
md5_hex = hashlib.md5(mac_norm.encode("utf-8")).hexdigest()
|
||||||
|
last_nibble = int(md5_hex[-1], 16)
|
||||||
|
if EVENODD == "even":
|
||||||
|
return (last_nibble % 2) == 0
|
||||||
|
return (last_nibble % 2) == 1
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
|
||||||
if params:
|
if params:
|
||||||
url = f"{url}?{urlencode(params)}"
|
url = f"{url}?{urlencode(params)}"
|
||||||
@@ -156,7 +304,7 @@ def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Op
|
|||||||
return None, 597
|
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):
|
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")
|
body = json.dumps(payload_obj).encode("utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
@@ -179,6 +327,27 @@ def http_post_json(url: str, payload_obj: Dict[str, Any], user: Optional[str] =
|
|||||||
return None, 597
|
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):
|
async def nb_problem(log_status, msg: str):
|
||||||
"""Increment counter and log a problem line."""
|
"""Increment counter and log a problem line."""
|
||||||
global NB_PROBLEM_COUNTER
|
global NB_PROBLEM_COUNTER
|
||||||
@@ -187,15 +356,28 @@ async def nb_problem(log_status, msg: str):
|
|||||||
await log_status(msg)
|
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).
|
- 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)
|
mac_norm = normalize_mac(mac)
|
||||||
if not mac_norm:
|
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("/")
|
base = NB_URL.rstrip("/")
|
||||||
h = {
|
h = {
|
||||||
@@ -205,15 +387,21 @@ def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[Optional[str], Option
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Step 1: MAC lookup
|
# 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:
|
if code != 200 or not data:
|
||||||
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac query http={code} mac={mac_norm}"))
|
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 []
|
results = (data or {}).get("results") or []
|
||||||
if not results:
|
if not results:
|
||||||
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac not found mac={mac_norm}"))
|
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]
|
rec = results[0]
|
||||||
aot = (rec.get("assigned_object_type") or "").strip()
|
aot = (rec.get("assigned_object_type") or "").strip()
|
||||||
@@ -224,39 +412,61 @@ def nb_lookup_device_by_mac(mac: str, log_status) -> Tuple[Optional[str], Option
|
|||||||
|
|
||||||
if not aot or aoid is None:
|
if not aot or aoid is None:
|
||||||
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac unassigned mac={mac_norm}"))
|
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":
|
if aot != "dcim.interface":
|
||||||
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm} iface_id={aoid}"))
|
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)
|
# 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:
|
if code2 != 200 or not iface:
|
||||||
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}"))
|
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 {}
|
dev = iface.get("device") or {}
|
||||||
host = dev.get("name") or dev.get("display")
|
host = dev.get("name") or dev.get("display")
|
||||||
dev_id = dev.get("id")
|
dev_id = dev.get("id")
|
||||||
if not host or dev_id is None:
|
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}"))
|
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)
|
# Step 3: Device detail (single fetch for custom_fields.*)
|
||||||
device, code3 = http_get_json(f"{base}/api/dcim/devices/{dev_id}/", headers=h)
|
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:
|
if code3 != 200 or not device:
|
||||||
# treat as "no tag info" (no problem increment)
|
# treat as "no extra info"
|
||||||
return host, aoid, dev_id, None, None
|
return host, aoid, dev_id, None, None, None, None, None
|
||||||
|
|
||||||
status_val = ((device.get("status") or {}).get("value")) or None
|
cf = device.get("custom_fields") or {}
|
||||||
tags = device.get("tags") or []
|
action_next = cf.get("action_next")
|
||||||
tag_slugs = set()
|
action_last = cf.get("action_last")
|
||||||
for t in tags:
|
action_next_timestamp = cf.get("action_next_timestamp")
|
||||||
slug = t.get("slug")
|
action_state = cf.get("action_state")
|
||||||
if isinstance(slug, str):
|
sot_ts = cf.get("sot_ts")
|
||||||
tag_slugs.add(slug)
|
|
||||||
|
|
||||||
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 +477,19 @@ async def main():
|
|||||||
ssl_ctx = make_ssl_context(args)
|
ssl_ctx = make_ssl_context(args)
|
||||||
|
|
||||||
print_lock = asyncio.Lock()
|
print_lock = asyncio.Lock()
|
||||||
|
event_log_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def log_status(s: str):
|
async def log_status(s: str):
|
||||||
async with print_lock:
|
async with print_lock:
|
||||||
print(s, file=sys.stderr, flush=True)
|
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"))
|
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
|
||||||
recent_payloads: Dict[bytes, float] = {}
|
recent_payloads: Dict[bytes, float] = {}
|
||||||
|
|
||||||
@@ -304,9 +522,40 @@ async def main():
|
|||||||
tls=ssl_ctx,
|
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()
|
t_start = monotonic()
|
||||||
|
local_queue_depth = processing_queue.qsize()
|
||||||
payload = msg.data
|
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
|
# strict payload dedupe
|
||||||
digest = hashlib.blake2b(payload, digest_size=16).digest()
|
digest = hashlib.blake2b(payload, digest_size=16).digest()
|
||||||
@@ -322,6 +571,7 @@ async def main():
|
|||||||
recent_payloads.pop(k, None)
|
recent_payloads.pop(k, None)
|
||||||
|
|
||||||
product = mac = fw = "-"
|
product = mac = fw = "-"
|
||||||
|
obj = None
|
||||||
try:
|
try:
|
||||||
text = payload.decode("utf-8", errors="replace")
|
text = payload.decode("utf-8", errors="replace")
|
||||||
obj = json.loads(text)
|
obj = json.loads(text)
|
||||||
@@ -329,58 +579,367 @@ async def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
event["product"] = product
|
||||||
|
event["mac"] = mac
|
||||||
|
event["fw"] = fw
|
||||||
|
|
||||||
|
mac_norm = normalize_mac(mac)
|
||||||
|
if not mac_belongs_to_this_shard(mac_norm):
|
||||||
|
return
|
||||||
|
|
||||||
|
event["mac_norm"] = mac_norm
|
||||||
|
if obj is not None:
|
||||||
|
try:
|
||||||
|
event["reg_age_s"] = extract_registration_age_s(obj)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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 = ""
|
host_suffix = ""
|
||||||
action_suffix = ""
|
action_suffix = "" # kept; not used
|
||||||
bell_prefix = "" # ASCII BEL when we have a tag match
|
bell_prefix = "" # ASCII BEL when action_next present (3x)
|
||||||
netbox_time_ms = 0.0
|
netbox_time_ms = 0.0
|
||||||
|
|
||||||
desired_slug = PRODUCT_TAG_SLUG.get(product)
|
|
||||||
if product == "fox100":
|
if product == "fox100":
|
||||||
nb_start = time.perf_counter()
|
nb_start = time.perf_counter()
|
||||||
try:
|
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:
|
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:
|
if iface_id and not host:
|
||||||
host_suffix += f" iface_id={iface_id}"
|
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:
|
if host:
|
||||||
|
# Gate on action_state: allow only "" or "ready"
|
||||||
|
action_state_str = ""
|
||||||
try:
|
try:
|
||||||
rmq_url = f"http://{RMQ_HOST}:{RMQ_PORT}/api/exchanges/{RMQ_VHOST}/{RMQ_EXCHANGE_WORK}/publish"
|
if action_state is None:
|
||||||
rmq_body = {
|
action_state_str = ""
|
||||||
"properties": {
|
elif isinstance(action_state, str):
|
||||||
"content_type": "application/json"
|
action_state_str = action_state.strip()
|
||||||
},
|
|
||||||
"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}")
|
|
||||||
else:
|
else:
|
||||||
# if response JSON has 'routed' false, note it (rmq-ikeja-pub3.sh warns in that case)
|
action_state_str = str(action_state).strip()
|
||||||
routed = False
|
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:
|
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:
|
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
|
routed = False
|
||||||
if not routed:
|
try:
|
||||||
await log_status(f"[{ts()}] rmq: publish immediate routed=false host={host}")
|
routed = bool((resp or {}).get("routed", False))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
await log_status(f"[{ts()}] rmq: unexpected error host={host!r} err={e!r}")
|
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:
|
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}")
|
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
|
netbox_time_ms = (time.perf_counter() - nb_start) * 1000
|
||||||
|
|
||||||
@@ -389,21 +948,59 @@ async def main():
|
|||||||
async with NB_PROBLEM_LOCK:
|
async with NB_PROBLEM_LOCK:
|
||||||
nb_problems_snapshot = NB_PROBLEM_COUNTER
|
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":
|
if product == "fox100":
|
||||||
line += f" netbox_ms={netbox_time_ms:.1f} total_ms={total_ms:.1f}"
|
line += f" netbox_ms={netbox_time_ms:.1f} total_ms={total_ms:.1f}"
|
||||||
if args.include_subject:
|
if args.include_subject:
|
||||||
line += f" subject={msg.subject}"
|
line += f" subject={msg.subject}"
|
||||||
|
|
||||||
async with print_lock:
|
async with print_lock:
|
||||||
# Prepend BEL only when we had a tag match
|
|
||||||
sys.stdout.write(bell_prefix + line + "\n")
|
sys.stdout.write(bell_prefix + line + "\n")
|
||||||
sys.stdout.flush()
|
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:
|
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:
|
else:
|
||||||
await nc.subscribe(args.subject, cb=message_handler)
|
await nc.subscribe(args.subject, cb=enqueue_message)
|
||||||
|
|
||||||
await log_status(
|
await log_status(
|
||||||
f"[{ts()}] Listening on subject '{args.subject}' (queue={args.queue or '-'}) via {args.servers} | nb=on"
|
f"[{ts()}] Listening on subject '{args.subject}' (queue={args.queue or '-'}) via {args.servers} | nb=on"
|
||||||
@@ -424,6 +1021,9 @@ async def main():
|
|||||||
|
|
||||||
await stop_event.wait()
|
await stop_event.wait()
|
||||||
await nc.drain()
|
await nc.drain()
|
||||||
|
await processing_queue.join()
|
||||||
|
await processing_queue.put(None)
|
||||||
|
await worker_task
|
||||||
await nc.close()
|
await nc.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -431,4 +1031,4 @@ if __name__ == "__main__":
|
|||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
Reference in New Issue
Block a user