16:19
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user