Ein UniFi AP hat seine Firmware aktualisiert und dabei einen neuen
DHCP-Lease gezogen. Gleicher Hostname, gleiche Seriennummer, trotzdem zwei
Assets: das neue ACTIVE, das alte INACTIVE, weil kein Geraet mehr darauf
zeigte.
Netdisco war ausschliesslich auf die kanonische Adresse gepinnt — und der
Hostname-Fallback ebenfalls, ueber pin_col=netdisco_device_ip. Das Asset mit
der ALTEN Adresse fiel damit aus genau der Abfrage heraus, die es haette
finden sollen. Alle drei Stufen der Kette haengen an derselben Adresse, also
half keine.
Die Seriennummer ist der einzige Wert, der Rename und Re-Adressierung
ueberlebt, und Netdisco meldet sie pro Geraet — sie wurde nur in den
Beschreibungstext geschrieben. Sie steht jetzt in assets.netdisco_serial und
matcht vor der Adresse.
Der Pin wird einmal gesetzt und nie ueberschrieben: Stack-Mitglieder
(VSF/IRF) melden einen Namen und eine Adresse, aber je eine Seriennummer,
und sie bleiben ein logisches Geraet — das Asset behaelt die erste Serial,
die uebrigen Chassis matchen weiter ueber die Adresse. Namensvettern bleiben
zwei Assets (40e9c29 gilt unveraendert). Platzhalter-Serials ("N/A", Nullen,
alles unter vier Zeichen) identifizieren nichts und fliegen vor dem Lookup
raus: darauf zu matchen waere der Fund am falschen Host, schlimmer als der
Fork, den das hier behebt.
Kein Backfill — der erste Sync nach 053 trifft jedes Geraet ueber seine in
dem Moment unveraenderte Adresse und stempelt die Serial dort. Bereits
geforkte Assets werden nicht zusammengefuehrt: an beiden Zeilen haengen
Findings und Audit-Trail, das bleibt eine manuelle Entscheidung.
test_asset_duplicate_hostname prueft nicht mehr auf den Namen der
Match-Stufe: sie heisst jetzt netdisco-serial statt netdisco-ip, fuehrt aber
auf dasselbe Asset. Die Stufe war nie die Zusage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
7.1 KiB
Python
169 lines
7.1 KiB
Python
"""A Netdisco device that gets a new address must stay one asset.
|
|
|
|
Field report 2026-09-10: one UniFi AP U6-Pro, firmware updated 6.9.1 → 8.8.7,
|
|
took a new DHCP lease in the process. Same hostname, same serial number, and
|
|
the inventory then showed TWO assets — the new address ACTIVE, the old one
|
|
INACTIVE, both carrying the same serial in their description.
|
|
|
|
`_find_or_create_asset` keyed a Netdisco device on its canonical IP: the pin
|
|
column is `netdisco_device_ip`, and the hostname fallback is pinned on it too,
|
|
so the old asset (pinned to the OLD address) was filtered out of the very
|
|
lookup meant to catch it. The address is not the identity; the serial is, and
|
|
Netdisco reports it in the same row. See netdisco_service._find_or_create_asset.
|
|
|
|
The address pin still has to stay for what it does handle: stack members
|
|
(VSF/IRF) report one name and one address per chassis but a serial EACH, and
|
|
they are one logical device, one firmware, one asset.
|
|
|
|
Run: python tests/test_netdisco_reip.py
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
|
|
|
|
from sqlalchemy import create_engine # noqa: E402
|
|
from sqlalchemy.orm import sessionmaker # noqa: E402
|
|
|
|
import app.models # noqa: F401,E402 (registers every mapper)
|
|
from app.models.base import Base # noqa: E402
|
|
from app.models.asset import Asset, AssetStatus # noqa: E402
|
|
from app.services import netdisco_service as nd # noqa: E402
|
|
|
|
|
|
def _db():
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
return sessionmaker(bind=engine)()
|
|
|
|
|
|
class _FakeClient:
|
|
"""The one thing a sync cannot have in a test: the HTTP call."""
|
|
|
|
def __init__(self, devices):
|
|
self._devices = devices
|
|
|
|
def get_devices(self):
|
|
return self._devices
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def _sync(db, *devices):
|
|
"""One REAL sync pass over `devices` — the loop, the rename, the reconcile.
|
|
|
|
Only `_build_client` is replaced. Everything the asset rows end up holding
|
|
is written by production code, so a rule that moves out of
|
|
`_find_or_create_asset` and into the loop stays covered.
|
|
"""
|
|
real = nd._build_client
|
|
nd._build_client = lambda cfg: _FakeClient(list(devices))
|
|
try:
|
|
return nd._run_netdisco_sync_locked(db, {"auto_create_assets": True})
|
|
finally:
|
|
nd._build_client = real
|
|
|
|
|
|
# The AP from the report. Firmware and address change together, name and
|
|
# serial do not.
|
|
AP = {"ip": "10.10.10.99", "name": "DEMO-AP-KANTINE-DE", "vendor": "ubiquiti",
|
|
"model": "U6-Pro", "os": "unifi", "os_version": "6.9.1",
|
|
"serial": "70A1B2C3D41F"}
|
|
AP_REIP = dict(AP, ip="10.10.10.57", os_version="8.8.7")
|
|
|
|
|
|
def demo():
|
|
db = _db()
|
|
|
|
# --- 1. first sync: the serial is recorded, not just described --------
|
|
st = _sync(db, AP)
|
|
assert (st["assets_created"], st["errors"]) == (1, [])
|
|
first = db.query(Asset).one()
|
|
assert first.netdisco_serial == "70A1B2C3D41F", first.netdisco_serial
|
|
assert "serial 70A1B2C3D41F" in first.description
|
|
|
|
# --- 2. new address, same device: one asset, not two ------------------
|
|
st = _sync(db, AP_REIP)
|
|
assert st["assets_created"] == 0, "the re-addressed AP forked a second asset"
|
|
assert st["assets_matched"] == 1, st
|
|
assert db.query(Asset).count() == 1, "the old asset was left behind"
|
|
|
|
# …carrying the current address, firmware and pin, and still ACTIVE. The
|
|
# INACTIVE row in the report was the reconcile finding an asset that no
|
|
# device pointed at any more.
|
|
a = db.query(Asset).one()
|
|
assert a.id == first.id
|
|
assert (a.ip_address, a.os_version) == ("10.10.10.57", "8.8.7")
|
|
assert a.netdisco_device_ip == "10.10.10.57", a.netdisco_device_ip
|
|
assert a.status == AssetStatus.ACTIVE
|
|
assert st["assets_inactivated"] == 0, st
|
|
|
|
# --- 3. a rename on top of the re-address is still the same device ----
|
|
# A serial match means the name Netdisco reports now IS the current one —
|
|
# the same reasoning the canonical-address match already had.
|
|
st = _sync(db, dict(AP, name="DEMO-AP-KANTINE-2-DE"))
|
|
assert st["assets_created"] == 0, st
|
|
a = db.query(Asset).one()
|
|
assert a.id == first.id and a.hostname == "DEMO-AP-KANTINE-2-DE", a.hostname
|
|
|
|
# --- 4. stack members stay one asset ----------------------------------
|
|
# Two chassis, one name, one address, a serial each — the address is what
|
|
# holds them together, so the asset keeps the first serial it saw and the
|
|
# other member matches on the address, in either order and on every sync.
|
|
db = _db()
|
|
stack = [{"ip": "192.0.2.10", "name": "sw-core-01", "vendor": "hp",
|
|
"model": "JL557A", "os": "hp", "os_version": "WC.16.11.0016",
|
|
"serial": s} for s in ("SN0000STACK1", "SN0000STACK2")]
|
|
for rows in (stack, list(reversed(stack)), stack):
|
|
st = _sync(db, *rows)
|
|
assert db.query(Asset).count() == 1, "a stack member forked its own asset"
|
|
assert st["assets_inactivated"] == 0, st
|
|
# Both serials still show up on the one asset — that listing is what an
|
|
# operator identifies the individual chassis by.
|
|
assert "serials SN0000STACK1, SN0000STACK2" in db.query(Asset).one().description
|
|
|
|
# --- 5. two devices sharing a name are still two devices --------------
|
|
# The guard from the duplicate-hostname fix: same factory name, different
|
|
# box. The serial is what tells them apart, so it must not merge them.
|
|
db = _db()
|
|
twins = [{"ip": "192.0.2.31", "name": "RackPDU", "vendor": "apc",
|
|
"serial": "SN0000TWIN1"},
|
|
{"ip": "192.0.2.32", "name": "RackPDU", "vendor": "apc",
|
|
"serial": "SN0000TWIN2"}]
|
|
_sync(db, *twins)
|
|
assert db.query(Asset).count() == 2, "two PDUs collapsed onto one asset"
|
|
|
|
# --- 6. a serial that identifies nothing identifies nothing -----------
|
|
# Vendors answer SNMP with placeholders. Matching on one would merge every
|
|
# device that gives the same non-answer onto a single asset — the finding
|
|
# on the wrong host, which is worse than the fork this fix is about.
|
|
db = _db()
|
|
junk = [{"ip": "192.0.2.41", "name": "ap-01", "serial": "000000000000"},
|
|
{"ip": "192.0.2.42", "name": "ap-02", "serial": "N/A"},
|
|
{"ip": "192.0.2.43", "name": "ap-03", "serial": "n/a"},
|
|
{"ip": "192.0.2.44", "name": "ap-04", "serial": "unknown"},
|
|
{"ip": "192.0.2.45", "name": "ap-05", "serial": "--"},
|
|
{"ip": "192.0.2.46", "name": "ap-06", "serial": "0"}]
|
|
_sync(db, *junk)
|
|
assert db.query(Asset).count() == 6, "placeholder serials merged assets"
|
|
assert db.query(Asset).filter(Asset.netdisco_serial.isnot(None)).count() == 0
|
|
|
|
# …and a device that answers no serial at all behaves as before: the
|
|
# address is all there is, and it still matches on it.
|
|
db = _db()
|
|
_sync(db, {"ip": "192.0.2.51", "name": "sw-nosn"})
|
|
st = _sync(db, {"ip": "192.0.2.51", "name": "sw-nosn"})
|
|
assert db.query(Asset).count() == 1 and st["assets_created"] == 0
|
|
|
|
print("test_netdisco_reip: OK")
|
|
|
|
|
|
def test_netdisco_reip():
|
|
demo()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
demo()
|