This commit is contained in:
2025-11-04 16:19:22 +02:00
parent 11d221e598
commit 81d709cad2

View File

@@ -9,15 +9,17 @@ Flow
2) NetBox: lookup device by name -> read custom_fields.cloud_id. 2) NetBox: lookup device by name -> read custom_fields.cloud_id.
3) Cloud: GET /v1/devices/{cloud_id}/liveness; if isConnected == false -> exit with "device is not online". 3) Cloud: GET /v1/devices/{cloud_id}/liveness; if isConnected == false -> exit with "device is not online".
4) Cloud: GET /v1/devices/{cloud_id} -> read firmwareVersion, ipAddress, nodeName, sectorName, smallCellName, serialNumber. 4) Cloud: GET /v1/devices/{cloud_id} -> read firmwareVersion, ipAddress, nodeName, sectorName, smallCellName, serialNumber.
5) NetBox updates (idempotent; change-only by default): 5) NetBox updates (idempotent by default):
- custom_fields.fw_version ← firmwareVersion - custom_fields.fw_version ← firmwareVersion
- custom_fields.nodeName ← nodeName - custom_fields.nodeName ← nodeName
- custom_fields.sectorName ← sectorName - custom_fields.sectorName ← sectorName
- custom_fields.smallCellName ← smallCellName - custom_fields.smallCellName ← smallCellName
- serial ← serialNumber - serial ← serialNumber
6) IP handling (only when online): 6) IP handling (only when online):
- If Cloud ipAddress is valid (not None/""/"0.0.0.0"): ensure eth0, ensure/create IP, MOVE if needed, set primary_ip4. - If Cloud ipAddress is valid (not None/""/"0.0.0.0"):
- If Cloud ipAddress is placeholder/invalid: skip IP changes; keep NetBox IP as-is. ensure eth0, ensure/create IP, MOVE if needed, set primary_ip4,
then PRUNE all other IPs on the device (default behavior).
- If Cloud ipAddress is placeholder/invalid: skip IP changes and keep NetBox IPs as-is.
7) Single-line OK/FAIL and proper exit code. 7) Single-line OK/FAIL and proper exit code.
Exit codes: Exit codes:
@@ -30,7 +32,7 @@ Exit codes:
import sys import sys
import json import json
import argparse import argparse
from typing import Optional, Union from typing import Optional, Union, List, Dict
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
@@ -181,6 +183,33 @@ class NetBox:
return (r.json().get("device") or {}).get("id") return (r.json().get("device") or {}).get("id")
return None return None
# --- Helpers for pruning ---
def list_device_ips(self, dev_id: int) -> List[Dict]:
# Try device_id filter (NetBox ≥3.6); otherwise fall back via interface scan
r = S_NB.get(self._url("/api/ipam/ip-addresses/"), headers=self._h(),
params={"device_id": dev_id, "limit": 1000}, timeout=REQ_TIMEOUT)
if r.status_code == 200:
return r.json().get("results") or []
# Fallback path
ips: List[Dict] = []
r2 = S_NB.get(self._url("/api/dcim/interfaces/"), headers=self._h(),
params={"device_id": dev_id, "limit": 1000}, timeout=REQ_TIMEOUT)
if r2.status_code == 200:
for iface in (r2.json().get("results") or []):
ifid = iface.get("id")
r3 = S_NB.get(self._url("/api/ipam/ip-addresses/"), headers=self._h(),
params={"assigned_object_type": "dcim.interface",
"assigned_object_id": ifid, "limit": 1000}, timeout=REQ_TIMEOUT)
if r3.status_code == 200:
ips.extend(r3.json().get("results") or [])
return ips
def delete_ip(self, ip_id: int) -> None:
log(f"NB: delete IP id={ip_id}")
r = S_NB.delete(self._url(f"/api/ipam/ip-addresses/{ip_id}/"), headers=self._h(), timeout=REQ_TIMEOUT)
if not (200 <= r.status_code < 300 or r.status_code == 204):
die(4, f"FAIL delete IP id={ip_id} HTTP={r.status_code} body={r.text[:200]}")
# ------------ Cloud API ------------ # ------------ Cloud API ------------
class Cloud: class Cloud:
def __init__(self, base: str, bearer: str): def __init__(self, base: str, bearer: str):
@@ -229,14 +258,14 @@ def run(hostname: str) -> None:
if cloud_id in (None, "", "null"): if cloud_id in (None, "", "null"):
die(1, f"FAIL {hostname} has no custom_fields.cloud_id in NetBox") die(1, f"FAIL {hostname} has no custom_fields.cloud_id in NetBox")
# 1) Liveness gate: proceed only if online # 1) Liveness gate
live = cl.device_liveness(cloud_id) live = cl.device_liveness(cloud_id)
if not bool(live.get("isConnected")): if not bool(live.get("isConnected")):
die(1, f"FAIL {hostname} cloud_id={cloud_id} device is not online") die(1, f"FAIL {hostname} cloud_id={cloud_id} device is not online")
current_nb_ip = _primary_ip4_text(dev_full) current_nb_ip = _primary_ip4_text(dev_full)
# 2) Cloud detail (now that we know it's online) # 2) Cloud detail
d = cl.device_detail(cloud_id) d = cl.device_detail(cloud_id)
fw = d.get("firmwareVersion") or d.get("version") fw = d.get("firmwareVersion") or d.get("version")
ip_from_cloud = (d.get("ipAddress") or "").strip() if isinstance(d.get("ipAddress"), str) else d.get("ipAddress") ip_from_cloud = (d.get("ipAddress") or "").strip() if isinstance(d.get("ipAddress"), str) else d.get("ipAddress")
@@ -248,7 +277,7 @@ def run(hostname: str) -> None:
if not fw: if not fw:
die(1, f"FAIL {hostname} cloud_id={cloud_id}: missing firmwareVersion") die(1, f"FAIL {hostname} cloud_id={cloud_id}: missing firmwareVersion")
# 3) NetBox patch (change-only) # 3) NetBox patch (idempotent)
cf_patch = {} cf_patch = {}
if cf.get("fw_version") != fw: if cf.get("fw_version") != fw:
cf_patch["fw_version"] = fw cf_patch["fw_version"] = fw
@@ -276,7 +305,7 @@ def run(hostname: str) -> None:
ip_out_for_status = current_nb_ip # default to current NB IP ip_out_for_status = current_nb_ip # default to current NB IP
if ip_is_placeholder: if ip_is_placeholder:
log(f"IP: cloud reported placeholder '{ip_from_cloud}', skipping IP changes; keeping NetBox ip={current_nb_ip}") log(f"IP: cloud reported placeholder '{ip_from_cloud}', skipping IP changes; keeping NetBox ip(s) as-is")
else: else:
iface_id = nb.ensure_eth0(dev_id) iface_id = nb.ensure_eth0(dev_id)
@@ -312,6 +341,16 @@ def run(hostname: str) -> None:
nb.device_set_primary_ip4(dev_id, ip_id) nb.device_set_primary_ip4(dev_id, ip_id)
ip_out_for_status = ip_from_cloud ip_out_for_status = ip_from_cloud
# PRUNE all other IPs on this device (default behavior)
all_ips = nb.list_device_ips(dev_id)
for rec in all_ips:
rid = rec.get("id")
if str(rid) == str(ip_id):
continue
addr = rec.get("address")
log(f"IP: pruning stale {addr} (id={rid}) from device {dev_id}")
nb.delete_ip(rid)
print(f"OK {hostname} ip={ip_out_for_status or 'NONE'} fw={fw} node={node} sector={sector} small={small}") print(f"OK {hostname} ip={ip_out_for_status or 'NONE'} fw={fw} node={node} sector={sector} small={small}")
# ------------ CLI ------------ # ------------ CLI ------------
@@ -321,7 +360,7 @@ if __name__ == "__main__":
ap.add_argument("--chatty", action="store_true", help="Verbose step-by-step logging to stderr") ap.add_argument("--chatty", action="store_true", help="Verbose step-by-step logging to stderr")
args = ap.parse_args() args = ap.parse_args()
CHATTY = bool(args.chatty) # module-scope assignment (no global needed) CHATTY = bool(args.chatty) # module-scope assignment
try: try:
run(args.hostname) run(args.hostname)
except requests.RequestException as e: except requests.RequestException as e: