Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eec0b3f9a | ||
|
|
b76519b0dd | ||
|
|
a2870a6e29 |
+7
-1
@@ -5,6 +5,12 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.3.1] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- Approving a device no longer fails after an upgrade from 3.2.0: a node the 3.3.0 inventory backfill could not link kept the old device columns on `nodes`, and their NOT NULL constraint rejected every new node. (#352)
|
||||
|
||||
## [3.3.0] - 2026-08-15
|
||||
|
||||
### Features
|
||||
@@ -20,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
### Fixes
|
||||
|
||||
- Alignment guides show for nodes inside groups and containers. (#337)
|
||||
- The status check interval is applied to the running scheduler. (#319) — thanks @JaG-v2
|
||||
- The status check interval is applied to the running scheduler. Thanks @JaG-v2. (#319)
|
||||
- Bumped undici and ip-address to patch high severity advisories. (#342)
|
||||
|
||||
### Docs
|
||||
|
||||
+67
-16
@@ -5,8 +5,9 @@ import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -531,13 +532,64 @@ _NODE_KEPT = (
|
||||
)
|
||||
|
||||
|
||||
async def _relax_legacy_node_columns(conn: AsyncConnection, info: list[Any]) -> None:
|
||||
"""Make the retained legacy `nodes` columns nullable — SQLite table rebuild.
|
||||
|
||||
The 3.2.0 schema declares `status`, `services`, `properties` and
|
||||
`show_hardware` NOT NULL with no server-side default. The 3.3.0 model no
|
||||
longer maps them, so every INSERT omits them and SQLite rejects the row —
|
||||
approving a device, creating a node, importing a canvas all fail with
|
||||
``NOT NULL constraint failed: nodes.status``. Dropping the columns is the
|
||||
real fix, but it waits on a complete backfill; until then they have to stop
|
||||
blocking writes. Values are preserved: only the constraint goes.
|
||||
"""
|
||||
kept = [row for row in info if row[1] in _LEGACY_NODE_COLUMNS]
|
||||
if not any(row[3] for row in kept): # PRAGMA `notnull`
|
||||
return # Already relaxed, or never constrained.
|
||||
|
||||
logger.info("Relaxing NOT NULL on the retained legacy node columns")
|
||||
legacy_defs = ",".join(f"{row[1]} {row[2] or 'VARCHAR'}" for row in kept)
|
||||
legacy_names = ", ".join(row[1] for row in kept)
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=f"{_NODE_COLUMNS_SQL},{legacy_defs}",
|
||||
copied=f"{_NODE_KEPT}, {legacy_names}",
|
||||
what="nodes legacy-column relax",
|
||||
)
|
||||
|
||||
|
||||
async def _rebuild_nodes(conn: AsyncConnection, *, columns_sql: str, copied: str, what: str) -> None:
|
||||
"""Recreate `nodes` with a new column list — SQLite cannot alter constraints.
|
||||
|
||||
Never fatal: a rebuild that fails leaves the table it could not replace, and
|
||||
the boot carries on. Foreign keys go off for the swap, because `edges` and
|
||||
`rack_devices` point at `nodes`, and back on in every case — the pragma is
|
||||
per connection and this one returns to the pool.
|
||||
"""
|
||||
try:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
# A previous attempt that failed after the create would block this one.
|
||||
await conn.exec_driver_sql("DROP TABLE IF EXISTS nodes_new")
|
||||
await conn.exec_driver_sql(f"CREATE TABLE nodes_new ({columns_sql})")
|
||||
await conn.exec_driver_sql(f"INSERT INTO nodes_new ({copied}) SELECT {copied} FROM nodes")
|
||||
await conn.exec_driver_sql("DROP TABLE nodes")
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes_new RENAME TO nodes")
|
||||
await conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)")
|
||||
except (OperationalError, IntegrityError) as exc:
|
||||
logger.warning("%s failed: %s", what, exc)
|
||||
finally:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _drop_legacy_node_columns() -> None:
|
||||
"""Remove the device columns from `nodes` (3.3.0) — SQLite table rebuild.
|
||||
|
||||
Runs only after the backfill has linked every device node to its inventory
|
||||
row. If any non-furniture node is still unlinked the drop is skipped and
|
||||
logged: the columns are the only remaining copy of that node's facts, and
|
||||
losing them is not recoverable.
|
||||
losing them is not recoverable. The skip then relaxes their NOT NULL instead,
|
||||
because the 3.3.0 model no longer writes them and the database must stay
|
||||
insertable while the backfill is retried on later boots.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
@@ -557,23 +609,16 @@ async def _drop_legacy_node_columns() -> None:
|
||||
"The backfill must link every device node before they can be dropped.",
|
||||
unlinked,
|
||||
)
|
||||
await _relax_legacy_node_columns(conn, list(info))
|
||||
return
|
||||
|
||||
logger.info("Migrating nodes: the device columns move to device_inventory")
|
||||
try:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(f"CREATE TABLE nodes_new ({_NODE_COLUMNS_SQL})")
|
||||
await conn.exec_driver_sql(
|
||||
f"INSERT INTO nodes_new ({_NODE_KEPT}) SELECT {_NODE_KEPT} FROM nodes"
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE nodes")
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes_new RENAME TO nodes")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)"
|
||||
)
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("nodes device-column drop failed: %s", exc)
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=_NODE_COLUMNS_SQL,
|
||||
copied=_NODE_KEPT,
|
||||
what="nodes device-column drop",
|
||||
)
|
||||
|
||||
|
||||
async def _backfill_node_devices() -> None:
|
||||
@@ -596,6 +641,12 @@ async def _backfill_node_devices() -> None:
|
||||
"Inventory backfill: linked %d node(s) — %d device(s) created, %d merged",
|
||||
stats["linked"], stats["created"], stats["merged"],
|
||||
)
|
||||
if stats.get("skipped"):
|
||||
logger.warning(
|
||||
"Inventory backfill: %d node(s) could not be linked; they keep their "
|
||||
"legacy columns and are retried on the next start.",
|
||||
stats["skipped"],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Inventory backfill failed: %s", exc)
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select, text
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
@@ -66,6 +67,50 @@ def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _same_ieee(left: str | None, right: str | None) -> bool:
|
||||
"""IEEE addresses compare case-insensitively — the same radio either way."""
|
||||
if _blank(left) or _blank(right):
|
||||
return False
|
||||
return str(left).lower() == str(right).lower()
|
||||
|
||||
|
||||
async def _ieee_owner(db: AsyncSession, ieee: str, *, other_than: str | None) -> InventoryDevice | None:
|
||||
"""The row already holding ``ieee``, if it is not ``other_than``.
|
||||
|
||||
``device_inventory.ieee_address`` is UNIQUE, so writing an address a second
|
||||
row owns raises. Callers ask first and leave the address where it is.
|
||||
"""
|
||||
stmt = select(InventoryDevice).where(func.lower(InventoryDevice.ieee_address) == ieee.lower())
|
||||
if other_than is not None:
|
||||
# `id != NULL` is NULL in SQL and would match nothing — only narrow when
|
||||
# there is a row to exclude.
|
||||
stmt = stmt.where(InventoryDevice.id != other_than)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
async def _drop_taken_ieee(
|
||||
db: AsyncSession, facts: Mapping[str, Any], device: InventoryDevice | None
|
||||
) -> Mapping[str, Any]:
|
||||
"""``facts`` without an ``ieee_address`` another inventory row already owns.
|
||||
|
||||
Returned unchanged in the common case. The column is UNIQUE, so writing a
|
||||
duplicate raises mid-merge — and during the backfill that would cost the node
|
||||
its link. The address belongs to whichever row holds it; identity for *this*
|
||||
device was established by ip or mac.
|
||||
"""
|
||||
ieee = facts.get("ieee_address")
|
||||
if _blank(ieee) or (device is not None and _same_ieee(device.ieee_address, ieee)):
|
||||
return facts
|
||||
owner = await _ieee_owner(db, str(ieee), other_than=device.id if device else None)
|
||||
if owner is None:
|
||||
return facts
|
||||
logger.warning(
|
||||
"IEEE %s is already held by device %s — leaving it off %s",
|
||||
ieee, owner.id, device.id if device else "the new row",
|
||||
)
|
||||
return {k: v for k, v in facts.items() if k != "ieee_address"}
|
||||
|
||||
|
||||
async def find_device_for(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -83,7 +128,10 @@ async def find_device_for(
|
||||
ip_toks = _ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
conds.append(InventoryDevice.ieee_address == ieee)
|
||||
# Case-insensitive: `0x00124B00…` and `0x00124b00…` are the same radio,
|
||||
# and an exact comparison here would mint a second row for it — one the
|
||||
# UNIQUE index then refuses.
|
||||
conds.append(func.lower(InventoryDevice.ieee_address) == ieee.lower())
|
||||
for tok in ip_toks:
|
||||
# Narrow with a substring match, then confirm per token below — an exact
|
||||
# comparison misses rows holding several addresses.
|
||||
@@ -98,7 +146,7 @@ async def find_device_for(
|
||||
).scalars().all()
|
||||
|
||||
for device in candidates:
|
||||
if ieee and device.ieee_address == ieee:
|
||||
if ieee and _same_ieee(device.ieee_address, ieee):
|
||||
return device
|
||||
for device in candidates:
|
||||
# "1.2.3.4" must not match "1.2.3.40" — confirm the token, don't trust
|
||||
@@ -360,6 +408,12 @@ async def link_facts(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
|
||||
# The IEEE is UNIQUE across the inventory. Where another row already owns the
|
||||
# one these facts carry, drop it rather than write a duplicate: identity has
|
||||
# been resolved to *this* device by ip or mac, and the address stays with the
|
||||
# row holding it.
|
||||
facts = await _drop_taken_ieee(db, facts, device)
|
||||
|
||||
if device is None:
|
||||
device = device_from_facts(facts)
|
||||
db.add(device)
|
||||
@@ -507,12 +561,13 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
|
||||
Reads the legacy columns with raw SQL because the model no longer declares
|
||||
them — this runs on the boot that drops them, once. Returns counts for the
|
||||
boot log. Does not commit.
|
||||
boot log, ``skipped`` among them: a node whose merge violates a constraint is
|
||||
left unlinked instead of aborting the run. Does not commit.
|
||||
"""
|
||||
legacy = await _legacy_columns_present(db)
|
||||
if not legacy:
|
||||
# Already migrated: the columns are gone, so nothing can be left to read.
|
||||
return {"linked": 0, "created": 0, "merged": 0}
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
placeholders = ", ".join(legacy)
|
||||
furniture = ", ".join(f"'{t}'" for t in sorted(FURNITURE_TYPES))
|
||||
@@ -526,9 +581,9 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
)
|
||||
).mappings().all()
|
||||
if not rows:
|
||||
return {"linked": 0, "created": 0, "merged": 0}
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
created = merged = 0
|
||||
linked = created = merged = skipped = 0
|
||||
for row in rows:
|
||||
facts: dict[str, Any] = {c: row[c] for c in legacy}
|
||||
facts["services"] = _decode_json(facts.get("services")) or []
|
||||
@@ -536,15 +591,30 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
facts["label"] = row["label"]
|
||||
facts["type"] = row["type"]
|
||||
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
device = await link_facts(db, node, facts, overwrite_scalars=True)
|
||||
if device is None:
|
||||
# One savepoint per node: a row the merge cannot write — a duplicate
|
||||
# IEEE, a constraint no longer met — is dropped on its own rather than
|
||||
# taking the whole backfill with it. Every node it does not link keeps
|
||||
# its legacy columns, so nothing is lost and the next boot retries.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
device = await link_facts(db, node, facts, overwrite_scalars=True)
|
||||
if device is None:
|
||||
continue
|
||||
await db.flush()
|
||||
except (IntegrityError, OperationalError) as exc:
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"Inventory backfill: node %s (%s) skipped — %s", row["id"], row["label"], exc
|
||||
)
|
||||
continue
|
||||
|
||||
linked += 1
|
||||
if existing is None:
|
||||
created += 1
|
||||
logger.info(
|
||||
@@ -558,4 +628,4 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
return {"linked": len(rows), "created": created, "merged": merged}
|
||||
return {"linked": linked, "created": created, "merged": merged, "skipped": skipped}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""The 3.2.0 → 3.3.0 upgrade of the `nodes` table.
|
||||
|
||||
3.3.0 moved the device facts off `nodes` onto `device_inventory` and dropped the
|
||||
columns. A real 3.2.0 database declares several of them NOT NULL with no
|
||||
server-side default (`status`, `services`, `properties`, `show_hardware`) — the
|
||||
schema here is `create_all`'s output at tag v3.2.0, not a hand-simplified copy,
|
||||
because that constraint is exactly what breaks: the 3.3.0 model no longer writes
|
||||
those columns, so if they survive the upgrade every INSERT fails with
|
||||
``NOT NULL constraint failed: nodes.status`` and approving a device is dead.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
import app.services.inventory_sync as inventory_sync
|
||||
|
||||
# `create_all` under v3.2.0. NOT NULL is reproduced exactly where it was.
|
||||
_NODES_320 = (
|
||||
"CREATE TABLE nodes ("
|
||||
"id VARCHAR NOT NULL PRIMARY KEY, type VARCHAR NOT NULL, label VARCHAR NOT NULL, "
|
||||
"design_id VARCHAR, hostname VARCHAR, ip VARCHAR, mac VARCHAR, os VARCHAR, "
|
||||
"status VARCHAR NOT NULL, check_method VARCHAR, check_target VARCHAR, "
|
||||
"services JSON NOT NULL, notes TEXT, pos_x FLOAT NOT NULL, pos_y FLOAT NOT NULL, "
|
||||
"parent_id VARCHAR, container_mode BOOLEAN NOT NULL, custom_colors JSON, "
|
||||
"custom_icon VARCHAR, cpu_count INTEGER, cpu_model VARCHAR, ram_gb FLOAT, "
|
||||
"disk_gb FLOAT, show_hardware BOOLEAN NOT NULL, show_port_numbers BOOLEAN NOT NULL, "
|
||||
"properties JSON NOT NULL, width FLOAT, height FLOAT, "
|
||||
"bottom_handles INTEGER NOT NULL, top_handles INTEGER NOT NULL, "
|
||||
"left_handles INTEGER NOT NULL, right_handles INTEGER NOT NULL, "
|
||||
"ieee_address VARCHAR, last_seen DATETIME, last_scan DATETIME, "
|
||||
"response_time_ms INTEGER, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
_DESIGNS_320 = (
|
||||
"CREATE TABLE designs (id VARCHAR NOT NULL PRIMARY KEY, name VARCHAR NOT NULL, "
|
||||
"design_type VARCHAR NOT NULL, icon VARCHAR, created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
|
||||
def _node_sql(node_id: str, label: str, node_type: str, ip: str | None) -> str:
|
||||
ip_sql = f"'{ip}'" if ip else "NULL"
|
||||
return (
|
||||
"INSERT INTO nodes (id, type, label, design_id, ip, status, services, pos_x, pos_y, "
|
||||
"container_mode, show_hardware, show_port_numbers, properties, bottom_handles, "
|
||||
"top_handles, left_handles, right_handles, created_at, updated_at) VALUES "
|
||||
f"('{node_id}', '{node_type}', '{label}', 'd1', {ip_sql}, 'online', '[]', 0, 0, "
|
||||
"0, 0, 0, '[]', 1, 1, 0, 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_320(tmp_path, monkeypatch):
|
||||
"""A v3.2.0 database, with the module-global engine pointed at it."""
|
||||
db_path = tmp_path / "v320.db"
|
||||
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
monkeypatch.setattr(
|
||||
database, "AsyncSessionLocal", database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
)
|
||||
return db_path, engine
|
||||
|
||||
|
||||
async def _build_320(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DESIGNS_320)
|
||||
await conn.exec_driver_sql(_NODES_320)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
||||
"VALUES ('d1', 'Network Topology', 'network', 'dashboard', "
|
||||
"'2026-01-01 00:00:00', '2026-01-01 00:00:00')"
|
||||
)
|
||||
await conn.exec_driver_sql(_node_sql("n1", "OpnSense", "firewall", "192.168.1.1"))
|
||||
await conn.exec_driver_sql(_node_sql("n2", "NAS", "nas", "192.168.1.2"))
|
||||
await conn.exec_driver_sql(_node_sql("n3", "a note", "text", None))
|
||||
|
||||
|
||||
async def _insert_node(engine) -> None:
|
||||
"""What approve does: insert a node the way the 3.3.0 model writes one."""
|
||||
from app.db.models import Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
session.add(Node(id="new", type="firewall", label="Approved", design_id="d1"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_upgrade_drops_the_device_columns_and_leaves_nodes_insertable(db_320):
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
|
||||
assert "status" not in cols
|
||||
assert "ip" not in cols
|
||||
assert "device_id" in cols
|
||||
# Every device node linked; the facts moved to the inventory row.
|
||||
linked = (await conn.exec_driver_sql(
|
||||
"SELECT label, device_id FROM nodes ORDER BY id"
|
||||
)).fetchall()
|
||||
assert [row[0] for row in linked] == ["OpnSense", "NAS", "a note"]
|
||||
assert linked[0][1] and linked[1][1]
|
||||
assert linked[2][1] is None # canvas furniture keeps no row
|
||||
ips = (await conn.exec_driver_sql(
|
||||
"SELECT ip FROM device_inventory ORDER BY ip"
|
||||
)).fetchall()
|
||||
assert [row[0] for row in ips] == ["192.168.1.1", "192.168.1.2"]
|
||||
|
||||
await _insert_node(engine)
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_node_the_backfill_cannot_link_still_leaves_nodes_insertable(db_320, monkeypatch):
|
||||
"""The regression: a failed backfill must not brick every later INSERT.
|
||||
|
||||
Before the fix, one unlinkable node kept `nodes.status` NOT NULL while the
|
||||
3.3.0 model stopped writing it, so approving *any* device raised
|
||||
``NOT NULL constraint failed: nodes.status`` (issue #351).
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
real_link_facts = inventory_sync.link_facts
|
||||
|
||||
async def link_facts(db, node, facts, **kwargs):
|
||||
if node.id == "n2":
|
||||
raise IntegrityError("boom", None, Exception("UNIQUE constraint failed"))
|
||||
return await real_link_facts(db, node, facts, **kwargs)
|
||||
|
||||
monkeypatch.setattr(inventory_sync, "link_facts", link_facts)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
cols = {c[1]: c for c in info}
|
||||
# The columns stay — they are the only remaining copy of n2's facts.
|
||||
assert "status" in cols
|
||||
# But nothing is NOT NULL any more, so the model can insert again.
|
||||
assert not any(c[3] for name, c in cols.items() if name in database._LEGACY_NODE_COLUMNS)
|
||||
# Values preserved by the rebuild.
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT status, ip FROM nodes WHERE id='n2'"
|
||||
)).fetchone()
|
||||
assert kept == ("online", "192.168.1.2")
|
||||
# The node that did link is linked; the failed one is not.
|
||||
linked = dict((await conn.exec_driver_sql(
|
||||
"SELECT id, device_id FROM nodes"
|
||||
)).fetchall())
|
||||
assert linked["n1"] is not None
|
||||
assert linked["n2"] is None
|
||||
|
||||
await _insert_node(engine)
|
||||
|
||||
# A boot where the failure persists changes nothing further: the relax is
|
||||
# a one-off, not a rebuild on every start.
|
||||
await database.init_db()
|
||||
async with check.begin() as conn:
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT status, ip FROM nodes WHERE id='n2'"
|
||||
)).fetchone()
|
||||
assert kept == ("online", "192.168.1.2")
|
||||
assert (await conn.exec_driver_sql(
|
||||
"SELECT COUNT(*) FROM nodes"
|
||||
)).scalar() == 4 # the three seeded, plus the one just approved
|
||||
|
||||
# A later boot retries the backfill: with the failure gone, n2 links and
|
||||
# the columns finally drop.
|
||||
monkeypatch.setattr(inventory_sync, "link_facts", real_link_facts)
|
||||
await database.init_db()
|
||||
async with check.begin() as conn:
|
||||
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
|
||||
assert "status" not in cols
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
@@ -17,6 +17,7 @@ from app.services.inventory_sync import (
|
||||
backfill_node_devices,
|
||||
changed_facts,
|
||||
find_device_for,
|
||||
link_facts,
|
||||
merge_properties,
|
||||
merge_services,
|
||||
)
|
||||
@@ -178,6 +179,60 @@ class TestFindDeviceFor:
|
||||
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee=None)
|
||||
assert found is not None and found.id == "d-hidden"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_an_ieee_written_in_another_case(self, db_session):
|
||||
"""One radio, one row: `ieee_address` is UNIQUE, so a case-sensitive
|
||||
match here would mint a second row the index then refuses."""
|
||||
db_session.add(InventoryDevice(id="d-1", ieee_address="0x00124b0022334455"))
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(
|
||||
db_session, ip=None, mac=None, ieee="0x00124B0022334455"
|
||||
)
|
||||
assert found is not None and found.id == "d-1"
|
||||
|
||||
|
||||
class TestIeeeCollisions:
|
||||
"""`device_inventory.ieee_address` is UNIQUE — a merge must never duplicate it."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_address_another_row_owns_is_left_where_it_is(self, db_session):
|
||||
"""The node is already linked, so identity is settled — the IEEE is not
|
||||
a reason to move it, and writing it here would violate the index."""
|
||||
design = await _design(db_session)
|
||||
db_session.add_all([
|
||||
InventoryDevice(id="d-radio", ieee_address="0xAAA"),
|
||||
InventoryDevice(id="d-host", ip="10.0.0.5"),
|
||||
])
|
||||
node = _node(design, device_id="d-host")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.id == "d-host"
|
||||
assert device.ieee_address is None
|
||||
radio = await db_session.get(InventoryDevice, "d-radio")
|
||||
assert radio is not None and radio.ieee_address == "0xAAA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_row_that_already_holds_it_keeps_writing_it(self, db_session):
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", ieee_address="0xAAA"))
|
||||
node = _node(design)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.ieee_address == "0xAAA"
|
||||
|
||||
|
||||
# --- the backfill -----------------------------------------------------------
|
||||
|
||||
@@ -238,7 +293,7 @@ class TestBackfill:
|
||||
db_session.add(_node(design))
|
||||
await db_session.commit()
|
||||
|
||||
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0}
|
||||
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_links_a_node_to_its_existing_row(self, db_session):
|
||||
@@ -251,7 +306,7 @@ class TestBackfill:
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 0, "merged": 1}
|
||||
assert stats == {"linked": 1, "created": 0, "merged": 1, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
|
||||
@@ -266,7 +321,7 @@ class TestBackfill:
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0}
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
@@ -342,7 +397,7 @@ class TestBackfill:
|
||||
await db_session.commit()
|
||||
|
||||
assert first["linked"] == 1
|
||||
assert second == {"linked": 0, "created": 0, "merged": 0}
|
||||
assert second == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user