0847
This commit is contained in:
369
files/nb_onedevice_update-old.py
Normal file
369
files/nb_onedevice_update-old.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
nb_sync_one_device.py
|
||||||
|
|
||||||
|
Update a single NetBox device from Cloud by hostname — ONLY if device is online.
|
||||||
|
|
||||||
|
Behavior
|
||||||
|
- Liveness gate via /v1/devices/{cloud_id}/liveness (no changes if offline).
|
||||||
|
- Updates NetBox fields from Cloud:
|
||||||
|
custom_fields.fw_version ← firmwareVersion
|
||||||
|
custom_fields.nodeName ← nodeName
|
||||||
|
custom_fields.sectorName ← sectorName
|
||||||
|
custom_fields.smallCellName ← smallCellName
|
||||||
|
serial ← serialNumber
|
||||||
|
- IP handling:
|
||||||
|
If Cloud ipAddress is valid (not None/""/"0.0.0.0"):
|
||||||
|
ensure eth0, ensure/create IP, MOVE from other device if needed, set primary_ip4,
|
||||||
|
then PRUNE all other IPs on this device (default).
|
||||||
|
If Cloud ipAddress is placeholder/invalid:
|
||||||
|
skip IP changes and do not prune.
|
||||||
|
- --chatty logs step-by-step to stderr; stdout remains one-line OK/FAIL.
|
||||||
|
- NEW: Logs custom field upgrade_cmd as `NB: upgrade_cmd=<value>` when --chatty.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 = success
|
||||||
|
1 = not found / missing data / offline
|
||||||
|
3 = network/HTTP error
|
||||||
|
4 = NetBox update error
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from typing import Optional, Union, List, Dict
|
||||||
|
|
||||||
|
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.eyJlbWFpbCI6InBhdmVsLmxAOGRldmljZXMuY29tIiwic3ViIjoyMiwiaWF0IjoxNzY5MDA5NDU0LCJleHAiOjE3NzE2MDE0NTR9.2oovrovOmWq_Ht2s7F2ldXm-vZcxAkqWg6Mra9LUadE"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# --- Helpers for pruning ---
|
||||||
|
def list_device_ips(self, dev_id: int) -> List[Dict]:
|
||||||
|
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 []
|
||||||
|
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 ------------
|
||||||
|
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()
|
||||||
|
|
||||||
|
def device_liveness(self, cloud_id: Union[str, int]) -> dict:
|
||||||
|
url = f"{self.base}/{cloud_id}/liveness"
|
||||||
|
log(f"CL: fetch liveness 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}/liveness HTTP={r.status_code}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
# ------------ Core ------------
|
||||||
|
def _primary_ip4_text(dev_json: dict) -> Optional[str]:
|
||||||
|
p = dev_json.get("primary_ip4") or {}
|
||||||
|
addr = p.get("address")
|
||||||
|
if isinstance(addr, str) and addr.endswith("/32"):
|
||||||
|
return addr[:-3]
|
||||||
|
return addr
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
# NEW: log upgrade command if present
|
||||||
|
upgrade_cmd = cf.get("upgrade_cmd")
|
||||||
|
log(f"NB: upgrade_cmd={upgrade_cmd}")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 1) Liveness gate
|
||||||
|
live = cl.device_liveness(cloud_id)
|
||||||
|
if not bool(live.get("isConnected")):
|
||||||
|
die(1, f"FAIL {hostname} cloud_id={cloud_id} device is not online")
|
||||||
|
|
||||||
|
current_nb_ip = _primary_ip4_text(dev_full)
|
||||||
|
|
||||||
|
# 2) Cloud detail
|
||||||
|
d = cl.device_detail(cloud_id)
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 3) NetBox patch (idempotent)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 4) IP handling (skip if placeholder)
|
||||||
|
ip_is_placeholder = (ip_from_cloud in (None, "", "0.0.0.0"))
|
||||||
|
ip_out_for_status = current_nb_ip # default to current NB IP
|
||||||
|
|
||||||
|
if ip_is_placeholder:
|
||||||
|
log(f"IP: cloud reported placeholder '{ip_from_cloud}', skipping IP changes; keeping NetBox ip(s) as-is")
|
||||||
|
else:
|
||||||
|
iface_id = nb.ensure_eth0(dev_id)
|
||||||
|
|
||||||
|
ip_rec = nb.get_ip_by_addr(ip_from_cloud)
|
||||||
|
if ip_rec is None:
|
||||||
|
log(f"IP: create new {ip_from_cloud} on iface {iface_id}")
|
||||||
|
ip_id = nb.create_ip_for_iface(ip_from_cloud, 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_from_cloud} 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_cloud} from iface={assigned_id} dev={other_dev} -> iface={iface_id} dev={dev_id}")
|
||||||
|
# Clear old device primary if necessary
|
||||||
|
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_from_cloud} to iface {iface_id}")
|
||||||
|
else:
|
||||||
|
die(4, f"FAIL IP {ip_from_cloud} assigned to {assigned_type}")
|
||||||
|
|
||||||
|
nb.device_set_primary_ip4(dev_id, ip_id)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# ------------ CLI ------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ap = argparse.ArgumentParser(description="Sync one NetBox device from Cloud by hostname (only if online)")
|
||||||
|
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()
|
||||||
|
|
||||||
|
CHATTY = bool(args.chatty) # module-scope assignment
|
||||||
|
try:
|
||||||
|
run(args.hostname)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
die(3, f"FAIL network error: {e}")
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
"""
|
"""
|
||||||
nb_sync_one_device.py
|
nb_sync_one_device.py
|
||||||
|
|
||||||
Update a single NetBox device from Cloud by hostname — ONLY if device is online.
|
Update a single NetBox device from Cloud by hostname.
|
||||||
|
|
||||||
Behavior
|
Behavior
|
||||||
- Liveness gate via /v1/devices/{cloud_id}/liveness (no changes if offline).
|
- Liveness gate via /v1/devices/{cloud_id}/liveness.
|
||||||
|
* If online: keep ORIGINAL behavior (Cloud is SoT, including IP).
|
||||||
|
* If offline: query Subsystem for the device and use Subsystem IP as fallback,
|
||||||
|
then continue with normal NetBox updates.
|
||||||
- Updates NetBox fields from Cloud:
|
- Updates NetBox fields from Cloud:
|
||||||
custom_fields.fw_version ← firmwareVersion
|
custom_fields.fw_version ← firmwareVersion
|
||||||
custom_fields.nodeName ← nodeName
|
custom_fields.nodeName ← nodeName
|
||||||
@@ -13,17 +16,17 @@ Behavior
|
|||||||
custom_fields.smallCellName ← smallCellName
|
custom_fields.smallCellName ← smallCellName
|
||||||
serial ← serialNumber
|
serial ← serialNumber
|
||||||
- IP handling:
|
- IP handling:
|
||||||
If Cloud ipAddress is valid (not None/""/"0.0.0.0"):
|
If chosen IP (Cloud when online, Subsystem when offline) is valid (not None/""/"0.0.0.0"):
|
||||||
ensure eth0, ensure/create IP, MOVE from other device if needed, set primary_ip4,
|
ensure eth0, ensure/create IP, MOVE from other device if needed, set primary_ip4,
|
||||||
then PRUNE all other IPs on this device (default).
|
then PRUNE all other IPs on this device (default).
|
||||||
If Cloud ipAddress is placeholder/invalid:
|
If chosen IP is placeholder/invalid:
|
||||||
skip IP changes and do not prune.
|
skip IP changes and do not prune.
|
||||||
- --chatty logs step-by-step to stderr; stdout remains one-line OK/FAIL.
|
- --chatty logs step-by-step to stderr; stdout remains one-line OK/FAIL.
|
||||||
- NEW: Logs custom field upgrade_cmd as `NB: upgrade_cmd=<value>` when --chatty.
|
- Logs custom field upgrade_cmd as `NB: upgrade_cmd=<value>` when --chatty.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 = success
|
0 = success
|
||||||
1 = not found / missing data / offline
|
1 = not found / missing data
|
||||||
3 = network/HTTP error
|
3 = network/HTTP error
|
||||||
4 = NetBox update error
|
4 = NetBox update error
|
||||||
"""
|
"""
|
||||||
@@ -31,7 +34,7 @@ Exit codes:
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
from typing import Optional, Union, List, Dict
|
from typing import Optional, Union, List, Dict, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
@@ -40,9 +43,15 @@ from urllib3.util.retry import Retry
|
|||||||
# ------------ HARD-CODED CONFIG (per Pavel) ------------
|
# ------------ HARD-CODED CONFIG (per Pavel) ------------
|
||||||
NB_URL = "http://netbox.gt-tiso.ikeja.co.za"
|
NB_URL = "http://netbox.gt-tiso.ikeja.co.za"
|
||||||
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623"
|
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623"
|
||||||
|
|
||||||
CLOUD_API_BASE = "https://cloud.ikeja.co.za/v1/devices"
|
CLOUD_API_BASE = "https://cloud.ikeja.co.za/v1/devices"
|
||||||
CLOUD_BEARER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBhdmVsLmxAOGRldmljZXMuY29tIiwic3ViIjoyMiwiaWF0IjoxNzY5MDA5NDU0LCJleHAiOjE3NzE2MDE0NTR9.2oovrovOmWq_Ht2s7F2ldXm-vZcxAkqWg6Mra9LUadE"
|
CLOUD_BEARER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBhdmVsLmxAOGRldmljZXMuY29tIiwic3ViIjoyMiwiaWF0IjoxNzY5MDA5NDU0LCJleHAiOjE3NzE2MDE0NTR9.2oovrovOmWq_Ht2s7F2ldXm-vZcxAkqWg6Mra9LUadE"
|
||||||
|
|
||||||
|
# NEW: Subsystem fallback (only used when Cloud liveness isConnected=false)
|
||||||
|
SUBSYSTEM_BASE = "https://subsystem.ikeja.co.za"
|
||||||
|
SUBSYSTEM_TOKEN = "HJL+&XRCoeHwh5?13@gxvg86qD#kQfgc"
|
||||||
|
SUBSYSTEM_OUTDOOR_ENDPOINT = "/customers/wave-devices/get-outdoor-devices"
|
||||||
|
|
||||||
REQ_TIMEOUT = 30
|
REQ_TIMEOUT = 30
|
||||||
CHATTY = False
|
CHATTY = False
|
||||||
|
|
||||||
@@ -57,13 +66,14 @@ def _new_session() -> requests.Session:
|
|||||||
respect_retry_after_header=True,
|
respect_retry_after_header=True,
|
||||||
)
|
)
|
||||||
adapter = HTTPAdapter(max_retries=retries, pool_connections=8, pool_maxsize=16)
|
adapter = HTTPAdapter(max_retries=retries, pool_connections=8, pool_maxsize=16)
|
||||||
s.mount('http://', adapter)
|
s.mount("http://", adapter)
|
||||||
s.mount('https://', adapter)
|
s.mount("https://", adapter)
|
||||||
s.headers['Accept'] = 'application/json'
|
s.headers["Accept"] = "application/json"
|
||||||
return s
|
return s
|
||||||
|
|
||||||
S_NB = _new_session()
|
S_NB = _new_session()
|
||||||
S_CL = _new_session()
|
S_CL = _new_session()
|
||||||
|
S_SUB = _new_session()
|
||||||
|
|
||||||
# ------------ Logging / status helpers ------------
|
# ------------ Logging / status helpers ------------
|
||||||
def log(msg: str):
|
def log(msg: str):
|
||||||
@@ -71,14 +81,21 @@ def log(msg: str):
|
|||||||
print(msg, file=sys.stderr)
|
print(msg, file=sys.stderr)
|
||||||
|
|
||||||
def die(code: int, msg: str):
|
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)
|
print(msg, file=sys.stdout if code == 0 else sys.stderr)
|
||||||
raise SystemExit(code)
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
def _is_placeholder_ip(v: Any) -> bool:
|
||||||
|
return v in (None, "", "0.0.0.0")
|
||||||
|
|
||||||
|
def _clean_ip(v: Any) -> Any:
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip()
|
||||||
|
return v
|
||||||
|
|
||||||
# ------------ NetBox API ------------
|
# ------------ NetBox API ------------
|
||||||
class NetBox:
|
class NetBox:
|
||||||
def __init__(self, base: str, token: str):
|
def __init__(self, base: str, token: str):
|
||||||
self.base = base.rstrip('/')
|
self.base = base.rstrip("/")
|
||||||
self.token = token
|
self.token = token
|
||||||
|
|
||||||
def _h(self):
|
def _h(self):
|
||||||
@@ -132,7 +149,6 @@ class NetBox:
|
|||||||
if r.status_code == 201:
|
if r.status_code == 201:
|
||||||
return r.json()["id"]
|
return r.json()["id"]
|
||||||
if r.status_code == 400:
|
if r.status_code == 400:
|
||||||
# race: read again
|
|
||||||
ifid = self.get_iface_id(dev_id, "eth0")
|
ifid = self.get_iface_id(dev_id, "eth0")
|
||||||
if ifid:
|
if ifid:
|
||||||
return ifid
|
return ifid
|
||||||
@@ -162,10 +178,7 @@ class NetBox:
|
|||||||
def assign_ip_to_iface(self, ip_id: int, iface_id: int) -> bool:
|
def assign_ip_to_iface(self, ip_id: int, iface_id: int) -> bool:
|
||||||
log(f"NB: assign IP id={ip_id} -> iface={iface_id}")
|
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(),
|
r = S_NB.patch(self._url(f"/api/ipam/ip-addresses/{ip_id}/"), headers=self._h(),
|
||||||
data=json.dumps({
|
data=json.dumps({"assigned_object_type": "dcim.interface", "assigned_object_id": iface_id}),
|
||||||
"assigned_object_type": "dcim.interface",
|
|
||||||
"assigned_object_id": iface_id
|
|
||||||
}),
|
|
||||||
timeout=REQ_TIMEOUT)
|
timeout=REQ_TIMEOUT)
|
||||||
return 200 <= r.status_code < 300
|
return 200 <= r.status_code < 300
|
||||||
|
|
||||||
@@ -182,24 +195,12 @@ 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]:
|
def list_device_ips(self, dev_id: int) -> List[Dict]:
|
||||||
r = S_NB.get(self._url("/api/ipam/ip-addresses/"), headers=self._h(),
|
r = S_NB.get(self._url("/api/ipam/ip-addresses/"), headers=self._h(),
|
||||||
params={"device_id": dev_id, "limit": 1000}, timeout=REQ_TIMEOUT)
|
params={"device_id": dev_id, "limit": 1000}, timeout=REQ_TIMEOUT)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
return r.json().get("results") or []
|
return r.json().get("results") or []
|
||||||
ips: List[Dict] = []
|
return []
|
||||||
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:
|
def delete_ip(self, ip_id: int) -> None:
|
||||||
log(f"NB: delete IP id={ip_id}")
|
log(f"NB: delete IP id={ip_id}")
|
||||||
@@ -232,6 +233,33 @@ class Cloud:
|
|||||||
die(3, f"FAIL Cloud GET {cloud_id}/liveness HTTP={r.status_code}")
|
die(3, f"FAIL Cloud GET {cloud_id}/liveness HTTP={r.status_code}")
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
# ------------ Subsystem fallback ------------
|
||||||
|
class Subsystem:
|
||||||
|
def __init__(self, base: str, token: str):
|
||||||
|
self.base = base.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def _h(self):
|
||||||
|
return {"token": self.token, "Accept": "application/json"}
|
||||||
|
|
||||||
|
def fetch_outdoor_devices(self) -> List[Dict[str, Any]]:
|
||||||
|
url = f"{self.base}{SUBSYSTEM_OUTDOOR_ENDPOINT}"
|
||||||
|
log("SUBSYS: fetch outdoor devices list")
|
||||||
|
r = S_SUB.post(url, headers=self._h(), timeout=REQ_TIMEOUT)
|
||||||
|
if r.status_code != 200:
|
||||||
|
die(3, f"FAIL Subsystem POST get-outdoor-devices HTTP={r.status_code}")
|
||||||
|
data = r.json()
|
||||||
|
return data.get("device_list") or []
|
||||||
|
|
||||||
|
def find_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
devs = self.fetch_outdoor_devices()
|
||||||
|
want = name.lower()
|
||||||
|
for rec in devs:
|
||||||
|
n = rec.get("name")
|
||||||
|
if isinstance(n, str) and n.lower() == want:
|
||||||
|
return rec
|
||||||
|
return None
|
||||||
|
|
||||||
# ------------ Core ------------
|
# ------------ Core ------------
|
||||||
def _primary_ip4_text(dev_json: dict) -> Optional[str]:
|
def _primary_ip4_text(dev_json: dict) -> Optional[str]:
|
||||||
p = dev_json.get("primary_ip4") or {}
|
p = dev_json.get("primary_ip4") or {}
|
||||||
@@ -243,6 +271,7 @@ def _primary_ip4_text(dev_json: dict) -> Optional[str]:
|
|||||||
def run(hostname: str) -> None:
|
def run(hostname: str) -> None:
|
||||||
nb = NetBox(NB_URL, NB_TOKEN)
|
nb = NetBox(NB_URL, NB_TOKEN)
|
||||||
cl = Cloud(CLOUD_API_BASE, CLOUD_BEARER)
|
cl = Cloud(CLOUD_API_BASE, CLOUD_BEARER)
|
||||||
|
sub = Subsystem(SUBSYSTEM_BASE, SUBSYSTEM_TOKEN)
|
||||||
|
|
||||||
dev = nb.get_device_by_name(hostname)
|
dev = nb.get_device_by_name(hostname)
|
||||||
if not dev:
|
if not dev:
|
||||||
@@ -252,7 +281,6 @@ def run(hostname: str) -> None:
|
|||||||
dev_full = nb.get_device(dev_id)
|
dev_full = nb.get_device(dev_id)
|
||||||
cf = dev_full.get("custom_fields") or {}
|
cf = dev_full.get("custom_fields") or {}
|
||||||
|
|
||||||
# NEW: log upgrade command if present
|
|
||||||
upgrade_cmd = cf.get("upgrade_cmd")
|
upgrade_cmd = cf.get("upgrade_cmd")
|
||||||
log(f"NB: upgrade_cmd={upgrade_cmd}")
|
log(f"NB: upgrade_cmd={upgrade_cmd}")
|
||||||
|
|
||||||
@@ -260,17 +288,20 @@ 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
|
# 1) Cloud liveness gate (ONLINE path stays as-is)
|
||||||
live = cl.device_liveness(cloud_id)
|
live = cl.device_liveness(cloud_id)
|
||||||
if not bool(live.get("isConnected")):
|
cloud_connected = bool(live.get("isConnected"))
|
||||||
die(1, f"FAIL {hostname} cloud_id={cloud_id} device is not online")
|
if cloud_connected:
|
||||||
|
log(f"CL: liveness isConnected=true for cloud_id={cloud_id}; using Cloud ipAddress as source of truth")
|
||||||
|
else:
|
||||||
|
log(f"CL: liveness isConnected=false for cloud_id={cloud_id}; falling back to Subsystem for IP")
|
||||||
|
|
||||||
current_nb_ip = _primary_ip4_text(dev_full)
|
current_nb_ip = _primary_ip4_text(dev_full)
|
||||||
|
|
||||||
# 2) Cloud detail
|
# 2) Cloud detail (still used for fw/node/sector/small/serial)
|
||||||
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")
|
cloud_ip = _clean_ip(d.get("ipAddress"))
|
||||||
node = d.get("nodeName")
|
node = d.get("nodeName")
|
||||||
sector = d.get("sectorName")
|
sector = d.get("sectorName")
|
||||||
small = d.get("smallCellName")
|
small = d.get("smallCellName")
|
||||||
@@ -279,6 +310,20 @@ 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")
|
||||||
|
|
||||||
|
# Choose IP source
|
||||||
|
chosen_ip = cloud_ip
|
||||||
|
ip_source = "cloud"
|
||||||
|
|
||||||
|
if not cloud_connected:
|
||||||
|
sub_rec = sub.find_by_name(hostname)
|
||||||
|
if not sub_rec:
|
||||||
|
# No subsystem record -> keep previous behavior: fail because offline and no fallback source
|
||||||
|
die(1, f"FAIL {hostname} cloud_id={cloud_id} device offline and not found in Subsystem")
|
||||||
|
sub_ip = _clean_ip(sub_rec.get("ip"))
|
||||||
|
chosen_ip = sub_ip
|
||||||
|
ip_source = "subsystem"
|
||||||
|
log(f"SUBSYS: {hostname} ip={sub_ip} (fallback)")
|
||||||
|
|
||||||
# 3) NetBox patch (idempotent)
|
# 3) NetBox patch (idempotent)
|
||||||
cf_patch = {}
|
cf_patch = {}
|
||||||
if cf.get("fw_version") != fw:
|
if cf.get("fw_version") != fw:
|
||||||
@@ -303,29 +348,30 @@ def run(hostname: str) -> None:
|
|||||||
nb.patch_device(dev_id, dev_patch)
|
nb.patch_device(dev_id, dev_patch)
|
||||||
|
|
||||||
# 4) IP handling (skip if placeholder)
|
# 4) IP handling (skip if placeholder)
|
||||||
ip_is_placeholder = (ip_from_cloud in (None, "", "0.0.0.0"))
|
chosen_ip = _clean_ip(chosen_ip)
|
||||||
|
ip_is_placeholder = _is_placeholder_ip(chosen_ip)
|
||||||
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(s) as-is")
|
log(f"IP: {ip_source} reported placeholder '{chosen_ip}', 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)
|
||||||
|
|
||||||
ip_rec = nb.get_ip_by_addr(ip_from_cloud)
|
ip_rec = nb.get_ip_by_addr(chosen_ip)
|
||||||
if ip_rec is None:
|
if ip_rec is None:
|
||||||
log(f"IP: create new {ip_from_cloud} on iface {iface_id}")
|
log(f"IP: create new {chosen_ip} on iface {iface_id} (source={ip_source})")
|
||||||
ip_id = nb.create_ip_for_iface(ip_from_cloud, iface_id)
|
ip_id = nb.create_ip_for_iface(chosen_ip, iface_id)
|
||||||
else:
|
else:
|
||||||
ip_id = ip_rec["id"]
|
ip_id = ip_rec["id"]
|
||||||
assigned_type = ip_rec.get("assigned_object_type") or ""
|
assigned_type = ip_rec.get("assigned_object_type") or ""
|
||||||
assigned_id = ip_rec.get("assigned_object_id")
|
assigned_id = ip_rec.get("assigned_object_id")
|
||||||
if not assigned_type:
|
if not assigned_type:
|
||||||
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
||||||
die(4, f"FAIL assign IP {ip_from_cloud} to iface {iface_id}")
|
die(4, f"FAIL assign IP {chosen_ip} to iface {iface_id}")
|
||||||
elif assigned_type == "dcim.interface":
|
elif assigned_type == "dcim.interface":
|
||||||
if str(assigned_id) != str(iface_id):
|
if str(assigned_id) != str(iface_id):
|
||||||
other_dev = nb.get_device_id_of_interface(assigned_id) if assigned_id else None
|
other_dev = nb.get_device_id_of_interface(assigned_id) if assigned_id else None
|
||||||
log(f"IP: moving {ip_from_cloud} from iface={assigned_id} dev={other_dev} -> iface={iface_id} dev={dev_id}")
|
log(f"IP: moving {chosen_ip} from iface={assigned_id} dev={other_dev} -> iface={iface_id} dev={dev_id} (source={ip_source})")
|
||||||
# Clear old device primary if necessary
|
# Clear old device primary if necessary
|
||||||
if other_dev:
|
if other_dev:
|
||||||
r = S_NB.get(nb._url(f"/api/dcim/devices/{other_dev}/"), headers=nb._h(), timeout=REQ_TIMEOUT)
|
r = S_NB.get(nb._url(f"/api/dcim/devices/{other_dev}/"), headers=nb._h(), timeout=REQ_TIMEOUT)
|
||||||
@@ -336,16 +382,15 @@ def run(hostname: str) -> None:
|
|||||||
headers=nb._h(), data=json.dumps({"primary_ip4": None}),
|
headers=nb._h(), data=json.dumps({"primary_ip4": None}),
|
||||||
timeout=REQ_TIMEOUT)
|
timeout=REQ_TIMEOUT)
|
||||||
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
||||||
die(4, f"FAIL move IP {ip_from_cloud} to iface {iface_id}")
|
die(4, f"FAIL move IP {chosen_ip} to iface {iface_id}")
|
||||||
else:
|
else:
|
||||||
die(4, f"FAIL IP {ip_from_cloud} assigned to {assigned_type}")
|
die(4, f"FAIL IP {chosen_ip} assigned to {assigned_type}")
|
||||||
|
|
||||||
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 = chosen_ip
|
||||||
|
|
||||||
# PRUNE all other IPs on this device (default behavior)
|
# PRUNE all other IPs on this device (default behavior)
|
||||||
all_ips = nb.list_device_ips(dev_id)
|
for rec in nb.list_device_ips(dev_id):
|
||||||
for rec in all_ips:
|
|
||||||
rid = rec.get("id")
|
rid = rec.get("id")
|
||||||
if str(rid) == str(ip_id):
|
if str(rid) == str(ip_id):
|
||||||
continue
|
continue
|
||||||
@@ -357,12 +402,12 @@ def run(hostname: str) -> None:
|
|||||||
|
|
||||||
# ------------ CLI ------------
|
# ------------ CLI ------------
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
ap = argparse.ArgumentParser(description="Sync one NetBox device from Cloud by hostname (only if online)")
|
ap = argparse.ArgumentParser(description="Sync one NetBox device from Cloud by hostname")
|
||||||
ap.add_argument("hostname", help="Device name in NetBox")
|
ap.add_argument("hostname", help="Device name in NetBox")
|
||||||
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
|
CHATTY = bool(args.chatty)
|
||||||
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