10:00
This commit is contained in:
@@ -2,21 +2,27 @@
|
|||||||
"""
|
"""
|
||||||
nb_sync_one_device.py
|
nb_sync_one_device.py
|
||||||
|
|
||||||
Update a single NetBox device from Cloud by hostname.
|
Update a single NetBox device from Cloud by hostname — ONLY if device is online.
|
||||||
|
|
||||||
Flow
|
Flow
|
||||||
1) Input: hostname (positional arg)
|
1) Input: hostname (positional arg).
|
||||||
2) Lookup device in NetBox by name -> get device id and CF cloud_id
|
2) NetBox: lookup device by name -> read custom_fields.cloud_id.
|
||||||
3) Fetch Cloud detail for that cloud_id -> read firmwareVersion, ipAddress, nodeName, sectorName, smallCellName, serialNumber
|
3) Cloud: GET /v1/devices/{cloud_id}/liveness; if isConnected == false -> exit with "device is not online".
|
||||||
4) Update NetBox:
|
4) Cloud: GET /v1/devices/{cloud_id} -> read firmwareVersion, ipAddress, nodeName, sectorName, smallCellName, serialNumber.
|
||||||
- custom_fields.fw_version, nodeName, sectorName, smallCellName
|
5) NetBox updates (idempotent; change-only by default):
|
||||||
- device.serial
|
- custom_fields.fw_version ← firmwareVersion
|
||||||
5) Ensure eth0 exists; ensure/create IP; **move IP from other device if necessary**; set primary_ip4 (clear old device's primary if needed)
|
- custom_fields.nodeName ← nodeName
|
||||||
6) Print ONE LINE result (OK/FAIL) and exit with appropriate code
|
- custom_fields.sectorName ← sectorName
|
||||||
|
- custom_fields.smallCellName ← smallCellName
|
||||||
|
- serial ← serialNumber
|
||||||
|
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 placeholder/invalid: skip IP changes; keep NetBox IP as-is.
|
||||||
|
7) Single-line OK/FAIL and proper exit code.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 = success
|
0 = success
|
||||||
1 = not found / missing data
|
1 = not found / missing data / offline
|
||||||
3 = network/HTTP error
|
3 = network/HTTP error
|
||||||
4 = NetBox update error
|
4 = NetBox update error
|
||||||
"""
|
"""
|
||||||
@@ -192,7 +198,22 @@ class Cloud:
|
|||||||
die(3, f"FAIL Cloud GET {cloud_id} HTTP={r.status_code}")
|
die(3, f"FAIL Cloud GET {cloud_id} HTTP={r.status_code}")
|
||||||
return r.json()
|
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 ------------
|
# ------------ 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:
|
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)
|
||||||
@@ -208,10 +229,17 @@ 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")
|
||||||
|
|
||||||
# Cloud detail
|
# 1) Liveness gate: proceed only if online
|
||||||
|
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 (now that we know it's online)
|
||||||
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 = d.get("ipAddress")
|
ip_from_cloud = (d.get("ipAddress") or "").strip() if isinstance(d.get("ipAddress"), str) else 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")
|
||||||
@@ -219,10 +247,8 @@ 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")
|
||||||
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)
|
# 3) NetBox patch (change-only)
|
||||||
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
|
||||||
@@ -245,51 +271,57 @@ def run(hostname: str) -> None:
|
|||||||
dev_patch["custom_fields"] = cf_patch
|
dev_patch["custom_fields"] = cf_patch
|
||||||
nb.patch_device(dev_id, dev_patch)
|
nb.patch_device(dev_id, dev_patch)
|
||||||
|
|
||||||
# Ensure eth0 + IP setup (move IPs to match Cloud truth)
|
# 4) IP handling (skip if placeholder)
|
||||||
iface_id = nb.ensure_eth0(dev_id)
|
ip_is_placeholder = (ip_from_cloud in (None, "", "0.0.0.0"))
|
||||||
|
ip_out_for_status = current_nb_ip # default to current NB IP
|
||||||
|
|
||||||
ip_rec = nb.get_ip_by_addr(ip)
|
if ip_is_placeholder:
|
||||||
if ip_rec is None:
|
log(f"IP: cloud reported placeholder '{ip_from_cloud}', skipping IP changes; keeping NetBox ip={current_nb_ip}")
|
||||||
log(f"IP: create new {ip} on iface {iface_id}")
|
|
||||||
ip_id = nb.create_ip_for_iface(ip, iface_id)
|
|
||||||
else:
|
else:
|
||||||
ip_id = ip_rec["id"]
|
iface_id = nb.ensure_eth0(dev_id)
|
||||||
assigned_type = ip_rec.get("assigned_object_type") or ""
|
|
||||||
assigned_id = ip_rec.get("assigned_object_id")
|
ip_rec = nb.get_ip_by_addr(ip_from_cloud)
|
||||||
if not assigned_type:
|
if ip_rec is None:
|
||||||
if not nb.assign_ip_to_iface(ip_id, iface_id):
|
log(f"IP: create new {ip_from_cloud} on iface {iface_id}")
|
||||||
die(4, f"FAIL assign IP {ip} to iface {iface_id}")
|
ip_id = nb.create_ip_for_iface(ip_from_cloud, 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:
|
else:
|
||||||
die(4, f"FAIL IP {ip} assigned to {assigned_type}")
|
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)
|
nb.device_set_primary_ip4(dev_id, ip_id)
|
||||||
|
ip_out_for_status = ip_from_cloud
|
||||||
|
|
||||||
print(f"OK {hostname} ip={ip} 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 ------------
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
ap = argparse.ArgumentParser(description="Sync one NetBox device from Cloud by hostname")
|
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("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()
|
||||||
|
|
||||||
# No global declaration needed here
|
CHATTY = bool(args.chatty) # module-scope assignment (no global needed)
|
||||||
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