From d96d6f3a767e29a5b416b3e8527d474b516a150e Mon Sep 17 00:00:00 2001 From: pavel Date: Tue, 4 Nov 2025 10:00:48 +0200 Subject: [PATCH] 10:00 --- files/nb_onedevice_update.py | 128 ++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 48 deletions(-) diff --git a/files/nb_onedevice_update.py b/files/nb_onedevice_update.py index 6e90b20..91ca9cd 100644 --- a/files/nb_onedevice_update.py +++ b/files/nb_onedevice_update.py @@ -2,21 +2,27 @@ """ 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 -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 +1) Input: hostname (positional arg). +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". +4) Cloud: GET /v1/devices/{cloud_id} -> read firmwareVersion, ipAddress, nodeName, sectorName, smallCellName, serialNumber. +5) NetBox updates (idempotent; change-only by default): + - custom_fields.fw_version ← firmwareVersion + - custom_fields.nodeName ← nodeName + - 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: 0 = success - 1 = not found / missing data + 1 = not found / missing data / offline 3 = network/HTTP error 4 = NetBox update error """ @@ -192,7 +198,22 @@ class Cloud: 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) @@ -208,10 +229,17 @@ def run(hostname: str) -> None: if cloud_id in (None, "", "null"): 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) 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") sector = d.get("sectorName") small = d.get("smallCellName") @@ -219,10 +247,8 @@ def run(hostname: str) -> None: 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) + # 3) NetBox patch (change-only) cf_patch = {} if cf.get("fw_version") != fw: cf_patch["fw_version"] = fw @@ -245,51 +271,57 @@ def run(hostname: str) -> None: 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) + # 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 - 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) + if ip_is_placeholder: + log(f"IP: cloud reported placeholder '{ip_from_cloud}', skipping IP changes; keeping NetBox ip={current_nb_ip}") 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}") + 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: - 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 ------------ 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("--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) + CHATTY = bool(args.chatty) # module-scope assignment (no global needed) try: run(args.hostname) except requests.RequestException as e: