09:31
This commit is contained in:
296
files/nb_onedevice_update.py
Normal file
296
files/nb_onedevice_update.py
Normal file
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
nb_sync_one_device.py
|
||||
|
||||
Update a single NetBox device from Cloud by hostname.
|
||||
|
||||
Flow
|
||||
1) Input: hostname (positional arg)
|
||||
2) Lookup device in NetBox by name -> get device id and CF cloud_id
|
||||
3) Fetch Cloud detail for that cloud_id -> read firmwareVersion, ipAddress, nodeName, sectorName, smallCellName, serialNumber
|
||||
4) Update NetBox:
|
||||
- custom_fields.fw_version, nodeName, sectorName, smallCellName
|
||||
- device.serial
|
||||
5) Ensure eth0 exists; ensure/create IP; **move IP from other device if necessary**; set primary_ip4 (clear old device's primary if needed)
|
||||
6) Print ONE LINE result (OK/FAIL) and exit with appropriate code
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = not found / missing data
|
||||
3 = network/HTTP error
|
||||
4 = NetBox update error
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# ------------ HARD-CODED CONFIG (per Pavel) ------------
|
||||
NB_URL = "http://netbox.gt-tiso.ikeja.co.za"
|
||||
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623"
|
||||
CLOUD_API_BASE = "https://cloud.ikeja.co.za/v1/devices"
|
||||
CLOUD_BEARER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBhdmVsLmxAOGRldmljZXMuY29tIiwic3ViIjoyMiwiaWF0IjoxNzU5NzMxMzk1LCJleHAiOjE3NjIzMjMzOTV9.C7XV-QHIsLPZTxavv1eU361p0KTpiEPfDv3AUTmAqG8"
|
||||
|
||||
REQ_TIMEOUT = 30
|
||||
CHATTY = False
|
||||
|
||||
# ------------ HTTP utilities ------------
|
||||
def _new_session() -> requests.Session:
|
||||
s = requests.Session()
|
||||
retries = Retry(
|
||||
total=3, connect=3, read=3, status=3,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=("GET", "POST", "PATCH", "PUT", "DELETE"),
|
||||
respect_retry_after_header=True,
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retries, pool_connections=8, pool_maxsize=16)
|
||||
s.mount('http://', adapter)
|
||||
s.mount('https://', adapter)
|
||||
s.headers['Accept'] = 'application/json'
|
||||
return s
|
||||
|
||||
S_NB = _new_session()
|
||||
S_CL = _new_session()
|
||||
|
||||
# ------------ Logging / status helpers ------------
|
||||
def log(msg: str):
|
||||
if CHATTY:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
def die(code: int, msg: str):
|
||||
# Single-line result: stdout on success, stderr on failure
|
||||
print(msg, file=sys.stdout if code == 0 else sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
# ------------ NetBox API ------------
|
||||
class NetBox:
|
||||
def __init__(self, base: str, token: str):
|
||||
self.base = base.rstrip('/')
|
||||
self.token = token
|
||||
|
||||
def _h(self):
|
||||
return {"Authorization": f"Token {self.token}", "Content-Type": "application/json"}
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.base}{path}"
|
||||
|
||||
def get_device_by_name(self, name: str) -> Optional[dict]:
|
||||
log(f"NB: lookup device by name {name}")
|
||||
r = S_NB.get(self._url("/api/dcim/devices/"), headers=self._h(),
|
||||
params={"name": name}, timeout=REQ_TIMEOUT)
|
||||
if r.status_code != 200:
|
||||
die(3, f"FAIL NetBox GET devices name={name} HTTP={r.status_code}")
|
||||
res = r.json().get("results") or []
|
||||
return res[0] if res else None
|
||||
|
||||
def get_device(self, dev_id: int) -> dict:
|
||||
log(f"NB: fetch device id={dev_id}")
|
||||
r = S_NB.get(self._url(f"/api/dcim/devices/{dev_id}/"), headers=self._h(), timeout=REQ_TIMEOUT)
|
||||
if r.status_code != 200:
|
||||
die(3, f"FAIL NetBox GET device id={dev_id} HTTP={r.status_code}")
|
||||
return r.json()
|
||||
|
||||
def patch_device(self, dev_id: int, patch: dict) -> None:
|
||||
if not patch:
|
||||
log("NB: no device patch needed")
|
||||
return
|
||||
log(f"NB: patch device id={dev_id} keys={list(patch.keys())}")
|
||||
r = S_NB.patch(self._url(f"/api/dcim/devices/{dev_id}/"), headers=self._h(),
|
||||
data=json.dumps(patch), timeout=REQ_TIMEOUT)
|
||||
if not (200 <= r.status_code < 300):
|
||||
die(4, f"FAIL patch device dev={dev_id} HTTP={r.status_code} body={r.text[:200]}")
|
||||
|
||||
def get_iface_id(self, dev_id: int, name: str) -> Optional[int]:
|
||||
r = S_NB.get(self._url("/api/dcim/interfaces/"), headers=self._h(),
|
||||
params={"device_id": dev_id, "name": name}, timeout=REQ_TIMEOUT)
|
||||
if r.status_code != 200:
|
||||
die(3, f"FAIL NetBox GET interfaces device_id={dev_id} HTTP={r.status_code}")
|
||||
res = r.json().get("results") or []
|
||||
return res[0]["id"] if res else None
|
||||
|
||||
def ensure_eth0(self, dev_id: int) -> int:
|
||||
ifid = self.get_iface_id(dev_id, "eth0")
|
||||
log(f"NB: ensure eth0 (current id={ifid})")
|
||||
if ifid:
|
||||
return ifid
|
||||
payload = {"device": dev_id, "name": "eth0", "type": "1000base-t"}
|
||||
r = S_NB.post(self._url("/api/dcim/interfaces/"), headers=self._h(),
|
||||
data=json.dumps(payload), timeout=REQ_TIMEOUT)
|
||||
if r.status_code == 201:
|
||||
return r.json()["id"]
|
||||
if r.status_code == 400:
|
||||
# race: read again
|
||||
ifid = self.get_iface_id(dev_id, "eth0")
|
||||
if ifid:
|
||||
return ifid
|
||||
die(4, f"FAIL create eth0 HTTP={r.status_code} body={r.text[:200]}")
|
||||
|
||||
def get_ip_by_addr(self, addr: str) -> Optional[dict]:
|
||||
r = S_NB.get(self._url("/api/ipam/ip-addresses/"), headers=self._h(),
|
||||
params={"address": f"{addr}/32"}, timeout=REQ_TIMEOUT)
|
||||
if r.status_code != 200:
|
||||
die(3, f"FAIL NetBox GET ip-addresses addr={addr} HTTP={r.status_code}")
|
||||
res = r.json().get("results") or []
|
||||
return res[0] if res else None
|
||||
|
||||
def create_ip_for_iface(self, addr: str, iface_id: int) -> int:
|
||||
payload = {
|
||||
"address": f"{addr}/32",
|
||||
"status": "active",
|
||||
"assigned_object_type": "dcim.interface",
|
||||
"assigned_object_id": iface_id,
|
||||
}
|
||||
r = S_NB.post(self._url("/api/ipam/ip-addresses/"), headers=self._h(),
|
||||
data=json.dumps(payload), timeout=REQ_TIMEOUT)
|
||||
if r.status_code == 201:
|
||||
return r.json()["id"]
|
||||
die(4, f"FAIL create IP {addr} HTTP={r.status_code} body={r.text[:200]}")
|
||||
|
||||
def assign_ip_to_iface(self, ip_id: int, iface_id: int) -> bool:
|
||||
log(f"NB: assign IP id={ip_id} -> iface={iface_id}")
|
||||
r = S_NB.patch(self._url(f"/api/ipam/ip-addresses/{ip_id}/"), headers=self._h(),
|
||||
data=json.dumps({
|
||||
"assigned_object_type": "dcim.interface",
|
||||
"assigned_object_id": iface_id
|
||||
}),
|
||||
timeout=REQ_TIMEOUT)
|
||||
return 200 <= r.status_code < 300
|
||||
|
||||
def device_set_primary_ip4(self, dev_id: int, ip_id: int) -> None:
|
||||
log(f"NB: set primary_ip4 dev={dev_id} -> ip_id={ip_id}")
|
||||
r = S_NB.patch(self._url(f"/api/dcim/devices/{dev_id}/"), headers=self._h(),
|
||||
data=json.dumps({"primary_ip4": ip_id}), timeout=REQ_TIMEOUT)
|
||||
if not (200 <= r.status_code < 300):
|
||||
die(4, f"FAIL set primary_ip4 dev={dev_id} ip_id={ip_id} HTTP={r.status_code} body={r.text[:200]}")
|
||||
|
||||
def get_device_id_of_interface(self, iface_id: int) -> Optional[int]:
|
||||
r = S_NB.get(self._url(f"/api/dcim/interfaces/{iface_id}/"), headers=self._h(), timeout=REQ_TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
return (r.json().get("device") or {}).get("id")
|
||||
return None
|
||||
|
||||
# ------------ Cloud API ------------
|
||||
class Cloud:
|
||||
def __init__(self, base: str, bearer: str):
|
||||
self.base = base.rstrip("/")
|
||||
self.bearer = bearer
|
||||
|
||||
def _h(self):
|
||||
return {"Authorization": f"Bearer {self.bearer}", "Accept": "application/json"}
|
||||
|
||||
def device_detail(self, cloud_id: Union[str, int]) -> dict:
|
||||
url = f"{self.base}/{cloud_id}"
|
||||
log(f"CL: fetch detail cloud_id={cloud_id}")
|
||||
r = S_CL.get(url, headers=self._h(), timeout=REQ_TIMEOUT)
|
||||
if r.status_code != 200:
|
||||
die(3, f"FAIL Cloud GET {cloud_id} HTTP={r.status_code}")
|
||||
return r.json()
|
||||
|
||||
# ------------ Core ------------
|
||||
def run(hostname: str) -> None:
|
||||
nb = NetBox(NB_URL, NB_TOKEN)
|
||||
cl = Cloud(CLOUD_API_BASE, CLOUD_BEARER)
|
||||
|
||||
dev = nb.get_device_by_name(hostname)
|
||||
if not dev:
|
||||
die(1, f"FAIL {hostname} not found in NetBox")
|
||||
|
||||
dev_id = dev["id"]
|
||||
dev_full = nb.get_device(dev_id)
|
||||
cf = dev_full.get("custom_fields") or {}
|
||||
cloud_id = cf.get("cloud_id")
|
||||
if cloud_id in (None, "", "null"):
|
||||
die(1, f"FAIL {hostname} has no custom_fields.cloud_id in NetBox")
|
||||
|
||||
# Cloud detail
|
||||
d = cl.device_detail(cloud_id)
|
||||
fw = d.get("firmwareVersion") or d.get("version")
|
||||
ip = d.get("ipAddress")
|
||||
node = d.get("nodeName")
|
||||
sector = d.get("sectorName")
|
||||
small = d.get("smallCellName")
|
||||
serial = d.get("serialNumber")
|
||||
|
||||
if not fw:
|
||||
die(1, f"FAIL {hostname} cloud_id={cloud_id}: missing firmwareVersion")
|
||||
if not ip or str(ip).lower() == "null":
|
||||
die(1, f"FAIL {hostname} cloud_id={cloud_id}: missing ipAddress")
|
||||
|
||||
# Build NetBox patch (only fields requested)
|
||||
cf_patch = {}
|
||||
if cf.get("fw_version") != fw:
|
||||
cf_patch["fw_version"] = fw
|
||||
log(f"CF: fw_version -> {fw}")
|
||||
if node and cf.get("nodeName") != node:
|
||||
cf_patch["nodeName"] = node
|
||||
log(f"CF: nodeName -> {node}")
|
||||
if sector and cf.get("sectorName") != sector:
|
||||
cf_patch["sectorName"] = sector
|
||||
log(f"CF: sectorName -> {sector}")
|
||||
if small and cf.get("smallCellName") != small:
|
||||
cf_patch["smallCellName"] = small
|
||||
log(f"CF: smallCellName -> {small}")
|
||||
|
||||
dev_patch = {}
|
||||
if serial and (dev_full.get("serial") != serial):
|
||||
dev_patch["serial"] = serial
|
||||
log(f"DEV: serial -> {serial}")
|
||||
if cf_patch:
|
||||
dev_patch["custom_fields"] = cf_patch
|
||||
nb.patch_device(dev_id, dev_patch)
|
||||
|
||||
# Ensure eth0 + IP setup (move IPs to match Cloud truth)
|
||||
iface_id = nb.ensure_eth0(dev_id)
|
||||
|
||||
ip_rec = nb.get_ip_by_addr(ip)
|
||||
if ip_rec is None:
|
||||
log(f"IP: create new {ip} on iface {iface_id}")
|
||||
ip_id = nb.create_ip_for_iface(ip, iface_id)
|
||||
else:
|
||||
ip_id = ip_rec["id"]
|
||||
assigned_type = ip_rec.get("assigned_object_type") or ""
|
||||
assigned_id = ip_rec.get("assigned_object_id")
|
||||
if not assigned_type:
|
||||
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
||||
die(4, f"FAIL assign IP {ip} to iface {iface_id}")
|
||||
elif assigned_type == "dcim.interface":
|
||||
if str(assigned_id) != str(iface_id):
|
||||
other_dev = nb.get_device_id_of_interface(assigned_id) if assigned_id else None
|
||||
log(f"IP: moving {ip} from iface={assigned_id} dev={other_dev} -> iface={iface_id} dev={dev_id}")
|
||||
# If old device has this as primary, clear its primary first
|
||||
if other_dev:
|
||||
r = S_NB.get(nb._url(f"/api/dcim/devices/{other_dev}/"), headers=nb._h(), timeout=REQ_TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
old_primary_id = (r.json().get("primary_ip4") or {}).get("id")
|
||||
if str(old_primary_id) == str(ip_id):
|
||||
S_NB.patch(nb._url(f"/api/dcim/devices/{other_dev}/"),
|
||||
headers=nb._h(), data=json.dumps({"primary_ip4": None}),
|
||||
timeout=REQ_TIMEOUT)
|
||||
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
||||
die(4, f"FAIL move IP {ip} to iface {iface_id}")
|
||||
else:
|
||||
die(4, f"FAIL IP {ip} assigned to {assigned_type}")
|
||||
|
||||
nb.device_set_primary_ip4(dev_id, ip_id)
|
||||
|
||||
print(f"OK {hostname} ip={ip} fw={fw} node={node} sector={sector} small={small}")
|
||||
|
||||
# ------------ CLI ------------
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="Sync one NetBox device from Cloud by hostname")
|
||||
ap.add_argument("hostname", help="Device name in NetBox")
|
||||
ap.add_argument("--chatty", action="store_true", help="Verbose step-by-step logging to stderr")
|
||||
args = ap.parse_args()
|
||||
|
||||
# No global declaration needed here
|
||||
CHATTY = bool(args.chatty)
|
||||
try:
|
||||
run(args.hostname)
|
||||
except requests.RequestException as e:
|
||||
die(3, f"FAIL network error: {e}")
|
||||
Reference in New Issue
Block a user