feat: proxmox import diagnostics, node style fix, and cluster edges
Scan History: - Proxmox is now a first-class scan kind (badge, filter chip, Server icon, completion toast) instead of being mislabeled as an IP scan. - A done run carrying a non-fatal advisory renders amber (info) with a warning toast, distinct from red failures. Import diagnostics: - test-connection probes /access/permissions and warns when the API token has no ACL (VMs/LXC would be invisible) — points at the PVEAuditor grant. - import surfaces an advisory when hosts import but no guests are visible (privilege-separated token whose rights are the intersection with the user), rather than a silent "done". Node style: - Proxmox container mode is now opt-in (container_mode === true), matching the rest of the codebase (App.tsx nesting logic). Imported proxmox nodes leave the flag unset and render like a manually-created node instead of an empty group container. Cluster edges: - Hosts from one import are chained with 'cluster' edges via left/right handles, distinct from the vertical host->guest 'virtual' edges. Wired for both the direct "Add to Canvas" path and the pending -> approve path (host<->host proxmox_cluster links, resolved to cluster edges on approve; cluster hosts get left/right handles). Tests added on both sides. ha-relevant: maybe
This commit is contained in:
@@ -9,9 +9,10 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.proxmox import _persist_pending_import
|
||||
from app.api.routes.proxmox import _guest_visibility_advisory, _persist_pending_import
|
||||
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -115,6 +116,22 @@ async def test_enable_sync_without_token_rejected(client: AsyncClient, headers:
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
# --- guest-visibility advisory ---------------------------------------------
|
||||
|
||||
def test_advisory_when_hosts_only() -> None:
|
||||
msg = _guest_visibility_advisory([_host_node()])
|
||||
assert msg is not None
|
||||
assert "PVEAuditor" in msg
|
||||
|
||||
|
||||
def test_no_advisory_when_guests_present() -> None:
|
||||
assert _guest_visibility_advisory([_host_node(), _guest_node(101, "10.0.0.5")]) is None
|
||||
|
||||
|
||||
def test_no_advisory_when_nothing_imported() -> None:
|
||||
assert _guest_visibility_advisory([]) is None
|
||||
|
||||
|
||||
# --- persistence / dedupe --------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -193,6 +210,64 @@ async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncCl
|
||||
assert res.json()[0]["properties"] == []
|
||||
|
||||
|
||||
# --- cluster links ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
||||
# Two hosts + a guest → one host↔host cluster link, one host→guest link.
|
||||
nodes = [
|
||||
{**_host_node(), "id": "pve-node-a", "ieee_address": "pve-node-a", "hostname": "a", "label": "a"},
|
||||
{**_host_node(), "id": "pve-node-b", "ieee_address": "pve-node-b", "hostname": "b", "label": "b"},
|
||||
_guest_node(101, "10.0.0.5"),
|
||||
]
|
||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
await _persist_pending_import(db_session, nodes, edges)
|
||||
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
|
||||
assert len(cluster) == 1
|
||||
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
|
||||
# Membership helper sees both endpoints.
|
||||
assert await _is_proxmox_cluster_member(db_session, "pve-node-a") is True
|
||||
assert await _is_proxmox_cluster_member(db_session, "pve-node-b") is True
|
||||
assert await _is_proxmox_cluster_member(db_session, "pve-pve1-101") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_host_records_no_cluster_link(db_session) -> None:
|
||||
await _persist_pending_import(db_session, [_host_node()], [])
|
||||
links = (await db_session.execute(
|
||||
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
|
||||
)).scalars().all()
|
||||
assert links == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
|
||||
# Two host nodes already on a canvas + a pending cluster link between them.
|
||||
design = Design(id=str(uuid.uuid4()), name="d")
|
||||
db_session.add(design)
|
||||
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
|
||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
||||
left_handles=1, right_handles=1)
|
||||
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", ieee_address="pve-node-b",
|
||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
||||
left_handles=1, right_handles=1)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(PendingDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
|
||||
discovery_source="proxmox_cluster",
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _resolve_pending_links_for_ieee(db_session, "pve-node-a")
|
||||
|
||||
edge = (await db_session.execute(select(Edge))).scalars().one()
|
||||
assert edge.type == "cluster"
|
||||
assert edge.source_handle == "right"
|
||||
assert edge.target_handle == "left-t"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_never_deletes(db_session) -> None:
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
|
||||
@@ -75,6 +75,25 @@ def test_parse_inventory_builds_host_guest_edges() -> None:
|
||||
assert edges == [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
|
||||
|
||||
def test_build_cluster_links_chains_hosts() -> None:
|
||||
nodes = [
|
||||
svc._host_node({"node": "pve-a", "status": "online"}),
|
||||
svc._guest_node({"vmid": 101, "status": "running"}, "pve-a", "qemu", None),
|
||||
svc._host_node({"node": "pve-b", "status": "online"}),
|
||||
svc._host_node({"node": "pve-c", "status": "online"}),
|
||||
]
|
||||
pairs = svc.build_proxmox_cluster_links(nodes)
|
||||
assert pairs == [("pve-node-pve-a", "pve-node-pve-b"), ("pve-node-pve-b", "pve-node-pve-c")]
|
||||
|
||||
|
||||
def test_build_cluster_links_single_host_is_not_a_cluster() -> None:
|
||||
nodes = [svc._host_node({"node": "pve-a", "status": "online"})]
|
||||
assert svc.build_proxmox_cluster_links(nodes) == []
|
||||
# Guests alone never form a cluster.
|
||||
guest = svc._guest_node({"vmid": 1, "status": "running"}, "pve-a", "qemu", None)
|
||||
assert svc.build_proxmox_cluster_links([guest]) == []
|
||||
|
||||
|
||||
def test_sanitize_error_hides_credentials() -> None:
|
||||
exc = httpx.HTTPStatusError(
|
||||
"boom", request=httpx.Request("GET", "https://h/api2/json"),
|
||||
@@ -115,9 +134,46 @@ async def test_fetch_inventory_happy_path() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_returns_message() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
if path == "/access/permissions":
|
||||
return {"/": {"VM.Audit": 1}} # token has an ACL
|
||||
return {"version": "8.2.2"}
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
||||
assert ok is True
|
||||
assert "8.2.2" in msg
|
||||
assert "warning" not in msg.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_warns_when_token_has_no_permissions() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
if path == "/access/permissions":
|
||||
return {} # privilege-separated token with no effective ACL
|
||||
return {"version": "8.4.19"}
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
||||
# Auth still succeeds; the message flags the permission gap.
|
||||
assert ok is True
|
||||
assert "8.4.19" in msg
|
||||
assert "PVEAuditor" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_has_permissions_treats_empty_as_no_perms() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
return {}
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
assert await svc._token_has_permissions(object()) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_has_permissions_assumes_ok_on_error() -> None:
|
||||
async def boom(client, path: str):
|
||||
raise httpx.ConnectError("nope")
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=boom)):
|
||||
# Never block a valid import on a permissions-probe failure.
|
||||
assert await svc._token_has_permissions(object()) is True
|
||||
|
||||
Reference in New Issue
Block a user