9670d0a86a
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
426 lines
15 KiB
Python
426 lines
15 KiB
Python
"""FastAPI router for Proxmox VE import + auto-sync config.
|
|
|
|
Fetches hosts/VMs/LXC from the Proxmox REST API and upserts them into the
|
|
pending inventory (same review→approve flow as scans and mesh imports).
|
|
|
|
Credentials: the API token comes from the request body when provided, else
|
|
falls back to the server-configured env token (``settings.proxmox_token_*``).
|
|
The token is never persisted by the app and never returned by any endpoint.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
from sqlalchemy import delete as sa_delete
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_user
|
|
from app.core.config import settings
|
|
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
|
|
from app.db.database import AsyncSessionLocal, get_db
|
|
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
|
from app.schemas.proxmox import (
|
|
ProxmoxConfig,
|
|
ProxmoxConnectionRequest,
|
|
ProxmoxEdgeOut,
|
|
ProxmoxImportPendingResponse,
|
|
ProxmoxImportResponse,
|
|
ProxmoxNodeOut,
|
|
ProxmoxTestConnectionResponse,
|
|
)
|
|
from app.schemas.scan import ScanRunResponse
|
|
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
|
from app.services.proxmox_service import (
|
|
build_proxmox_cluster_links,
|
|
build_proxmox_properties,
|
|
fetch_proxmox_inventory,
|
|
merge_proxmox_properties,
|
|
test_proxmox_connection,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
# Discovery sources for the two proxmox link shapes. Host→guest links render as
|
|
# 'virtual' edges; host↔host cluster links render as 'cluster' edges.
|
|
_PROXMOX_GUEST_SOURCE = "proxmox"
|
|
_PROXMOX_CLUSTER_SOURCE = "proxmox_cluster"
|
|
|
|
|
|
def _resolve_credentials(payload: ProxmoxConnectionRequest) -> tuple[str, str]:
|
|
"""Pick the API token: request body first, else server env config.
|
|
|
|
Raises HTTP 400 when neither carries a token.
|
|
"""
|
|
token_id = payload.token_id or settings.proxmox_token_id
|
|
token_secret = payload.token_secret or settings.proxmox_token_secret
|
|
if not token_id or not token_secret:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No Proxmox API token provided and none configured on the server.",
|
|
)
|
|
return token_id, token_secret
|
|
|
|
|
|
@router.post("/test-connection", response_model=ProxmoxTestConnectionResponse)
|
|
async def test_connection_endpoint(
|
|
payload: ProxmoxConnectionRequest,
|
|
_: str = Depends(get_current_user),
|
|
) -> ProxmoxTestConnectionResponse:
|
|
"""Validate host reachability + token before importing."""
|
|
token_id, token_secret = _resolve_credentials(payload)
|
|
connected, message = await test_proxmox_connection(
|
|
host=payload.host,
|
|
port=payload.port,
|
|
token_id=token_id,
|
|
token_secret=token_secret,
|
|
verify_tls=payload.verify_tls,
|
|
)
|
|
return ProxmoxTestConnectionResponse(connected=connected, message=message)
|
|
|
|
|
|
@router.post("/import", response_model=ProxmoxImportResponse)
|
|
async def import_proxmox(
|
|
payload: ProxmoxConnectionRequest,
|
|
_: str = Depends(get_current_user),
|
|
) -> ProxmoxImportResponse:
|
|
"""Fetch the inventory and return nodes + edges ready for canvas drop."""
|
|
token_id, token_secret = _resolve_credentials(payload)
|
|
try:
|
|
nodes_raw, edges_raw = await fetch_proxmox_inventory(
|
|
host=payload.host,
|
|
port=payload.port,
|
|
token_id=token_id,
|
|
token_secret=token_secret,
|
|
verify_tls=payload.verify_tls,
|
|
)
|
|
except ConnectionError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.exception("Unexpected error during Proxmox import")
|
|
raise HTTPException(status_code=500, detail="Unexpected error during Proxmox import") from exc
|
|
|
|
nodes = [ProxmoxNodeOut(**n) for n in nodes_raw]
|
|
edges = [ProxmoxEdgeOut(**e) for e in edges_raw]
|
|
return ProxmoxImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
|
|
|
|
|
@router.post("/import-pending", response_model=ScanRunResponse)
|
|
async def import_proxmox_to_pending(
|
|
payload: ProxmoxConnectionRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> ScanRun:
|
|
"""Queue a Proxmox pending import as a background scan run (kind=proxmox)."""
|
|
token_id, token_secret = _resolve_credentials(payload)
|
|
run = ScanRun(
|
|
status="running",
|
|
kind="proxmox",
|
|
ranges=[f"{payload.host}:{payload.port}"],
|
|
)
|
|
db.add(run)
|
|
await db.commit()
|
|
await db.refresh(run)
|
|
background_tasks.add_task(
|
|
_background_proxmox_import,
|
|
run.id,
|
|
payload.host,
|
|
payload.port,
|
|
token_id,
|
|
token_secret,
|
|
payload.verify_tls,
|
|
)
|
|
return run
|
|
|
|
|
|
async def _background_proxmox_import(
|
|
run_id: str,
|
|
host: str,
|
|
port: int,
|
|
token_id: str,
|
|
token_secret: str,
|
|
verify_tls: bool,
|
|
) -> None:
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
nodes_raw, edges_raw = await fetch_proxmox_inventory(
|
|
host=host,
|
|
port=port,
|
|
token_id=token_id,
|
|
token_secret=token_secret,
|
|
verify_tls=verify_tls,
|
|
)
|
|
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
|
run = await db.get(ScanRun, run_id)
|
|
if run:
|
|
run.status = "done"
|
|
run.devices_found = result.device_count
|
|
run.finished_at = datetime.now(timezone.utc)
|
|
run.error = _guest_visibility_advisory(nodes_raw)
|
|
await db.commit()
|
|
except Exception as exc:
|
|
logger.exception("Proxmox import %s failed", run_id)
|
|
await db.rollback()
|
|
run = await db.get(ScanRun, run_id)
|
|
if run:
|
|
run.status = "error"
|
|
run.error = str(exc)[:500]
|
|
run.finished_at = datetime.now(timezone.utc)
|
|
await db.commit()
|
|
|
|
|
|
def _guest_visibility_advisory(nodes_raw: list[dict[str, Any]]) -> str | None:
|
|
"""Non-fatal advisory when hosts import but no VMs/LXC were visible.
|
|
|
|
A Proxmox API token that lacks ``VM.Audit`` sees empty ``qemu``/``lxc`` lists
|
|
(HTTP 200, no error), so only host nodes come through. The usual cause is a
|
|
privilege-separated token whose effective rights are the *intersection* with
|
|
the user's rights — granting PVEAuditor to the token alone is not enough when
|
|
the user has none. Surface that instead of a silent success.
|
|
"""
|
|
hosts = sum(1 for n in nodes_raw if n.get("type") == "proxmox")
|
|
guests = len(nodes_raw) - hosts
|
|
if hosts and not guests:
|
|
return (
|
|
f"Imported {hosts} host(s) but no VMs or LXC were visible to the API "
|
|
"token. Grant the PVEAuditor role at path '/' to BOTH the token and "
|
|
"the user (privilege-separated tokens get the intersection of token "
|
|
"and user rights), then re-import."
|
|
)
|
|
return None
|
|
|
|
|
|
async def _persist_pending_import(
|
|
db: AsyncSession,
|
|
nodes_raw: list[dict[str, Any]],
|
|
edges_raw: list[dict[str, Any]],
|
|
) -> ProxmoxImportPendingResponse:
|
|
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
|
|
|
|
Two-tier identity (order matters):
|
|
1. Match an existing canvas Node or pending row by **IP** (merge into a
|
|
device previously found by a scan) — never duplicate.
|
|
2. Else match by synthetic ``ieee_address`` (``pve-...``).
|
|
|
|
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
|
|
"""
|
|
await dedupe_nodes_by_ieee(db)
|
|
|
|
cluster_pairs = build_proxmox_cluster_links(nodes_raw)
|
|
cluster_members = {ieee for pair in cluster_pairs for ieee in pair}
|
|
|
|
pending_created = 0
|
|
pending_updated = 0
|
|
|
|
for n in nodes_raw:
|
|
ieee = n.get("ieee_address")
|
|
if not ieee:
|
|
continue
|
|
ip = n.get("ip")
|
|
props = build_proxmox_properties(n)
|
|
|
|
# 1) Already on a canvas? Match by ieee OR (ip when known). Refresh in
|
|
# place: merge properties, adopt the pve identity onto a scanned node,
|
|
# backfill blank specs/hostname. Do NOT stomp user-set type/status.
|
|
node_filter = [Node.ieee_address == ieee]
|
|
if ip:
|
|
node_filter.append(Node.ip == ip)
|
|
existing_nodes = (
|
|
await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
|
|
).scalars().all()
|
|
|
|
if existing_nodes:
|
|
for en in existing_nodes:
|
|
en.properties = merge_proxmox_properties(en.properties, props)
|
|
if not en.ieee_address:
|
|
en.ieee_address = ieee
|
|
if ip and not en.ip:
|
|
en.ip = ip
|
|
en.hostname = en.hostname or n.get("hostname")
|
|
en.cpu_count = en.cpu_count or n.get("cpu_count")
|
|
en.ram_gb = en.ram_gb or n.get("ram_gb")
|
|
en.disk_gb = en.disk_gb or n.get("disk_gb")
|
|
# A cluster host needs one left + one right handle for the
|
|
# cluster edge endpoints (both default to 0).
|
|
if ieee in cluster_members:
|
|
en.left_handles = max(en.left_handles or 0, 1)
|
|
en.right_handles = max(en.right_handles or 0, 1)
|
|
await _ensure_inventory_row(db, ieee, ip, n, props, approved=True)
|
|
pending_updated += 1
|
|
continue
|
|
|
|
# 2) Not on canvas — upsert the pending inventory row.
|
|
pending = await _find_pending(db, ieee, ip)
|
|
if pending is None:
|
|
db.add(_new_pending(ieee, ip, n, props, status="pending"))
|
|
pending_created += 1
|
|
else:
|
|
_refresh_pending(pending, ieee, ip, n, props)
|
|
pending_updated += 1
|
|
|
|
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
|
|
await db.commit()
|
|
|
|
return ProxmoxImportPendingResponse(
|
|
pending_created=pending_created,
|
|
pending_updated=pending_updated,
|
|
links_recorded=links_recorded,
|
|
device_count=len(nodes_raw),
|
|
)
|
|
|
|
|
|
async def _find_pending(
|
|
db: AsyncSession, ieee: str, ip: str | None
|
|
) -> PendingDevice | None:
|
|
filters = [PendingDevice.ieee_address == ieee]
|
|
if ip:
|
|
filters.append(PendingDevice.ip == ip)
|
|
return (
|
|
await db.execute(select(PendingDevice).where(or_(*filters)))
|
|
).scalars().first()
|
|
|
|
|
|
def _new_pending(
|
|
ieee: str, ip: str | None, n: dict[str, Any], props: list[dict[str, Any]], status: str
|
|
) -> PendingDevice:
|
|
return PendingDevice(
|
|
ieee_address=ieee,
|
|
ip=ip,
|
|
hostname=n.get("hostname"),
|
|
friendly_name=n.get("label"),
|
|
suggested_type=n.get("type"),
|
|
vendor=n.get("vendor"),
|
|
model=n.get("model"),
|
|
properties=props,
|
|
status=status,
|
|
discovery_source="proxmox",
|
|
)
|
|
|
|
|
|
def _refresh_pending(
|
|
pending: PendingDevice,
|
|
ieee: str,
|
|
ip: str | None,
|
|
n: dict[str, Any],
|
|
props: list[dict[str, Any]],
|
|
) -> None:
|
|
pending.ieee_address = pending.ieee_address or ieee
|
|
pending.ip = ip or pending.ip
|
|
pending.hostname = n.get("hostname") or pending.hostname
|
|
pending.friendly_name = n.get("label") or pending.friendly_name
|
|
pending.suggested_type = n.get("type") or pending.suggested_type
|
|
pending.vendor = n.get("vendor") or pending.vendor
|
|
pending.model = n.get("model") or pending.model
|
|
pending.properties = merge_proxmox_properties(list(pending.properties or []), props)
|
|
if pending.status == "approved":
|
|
# Approved earlier but the canvas Node is gone — revive so it reappears.
|
|
pending.status = "pending"
|
|
# hidden stays hidden.
|
|
|
|
|
|
async def _ensure_inventory_row(
|
|
db: AsyncSession,
|
|
ieee: str,
|
|
ip: str | None,
|
|
n: dict[str, Any],
|
|
props: list[dict[str, Any]],
|
|
approved: bool,
|
|
) -> None:
|
|
"""Ensure an inventory row exists for a device already on a canvas, so it
|
|
shows in the inventory with an 'In N canvas' badge. Never changes status."""
|
|
inv = await _find_pending(db, ieee, ip)
|
|
if inv is None:
|
|
db.add(_new_pending(ieee, ip, n, props, status="approved" if approved else "pending"))
|
|
else:
|
|
inv.ieee_address = inv.ieee_address or ieee
|
|
inv.ip = ip or inv.ip
|
|
inv.hostname = n.get("hostname") or inv.hostname
|
|
inv.suggested_type = n.get("type") or inv.suggested_type
|
|
inv.properties = merge_proxmox_properties(list(inv.properties or []), props)
|
|
|
|
|
|
async def _replace_links(
|
|
db: AsyncSession,
|
|
edges_raw: list[dict[str, Any]],
|
|
cluster_pairs: list[tuple[str, str]],
|
|
) -> int:
|
|
"""Wipe all proxmox-source links and re-insert the freshly discovered set.
|
|
|
|
Two link shapes: host→guest (``proxmox`` → 'virtual' edges) and host↔host
|
|
(``proxmox_cluster`` → 'cluster' edges).
|
|
"""
|
|
await db.execute(
|
|
sa_delete(PendingDeviceLink).where(
|
|
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
|
)
|
|
)
|
|
recorded = 0
|
|
seen: set[tuple[str, str]] = set()
|
|
|
|
def _add(src: str | None, tgt: str | None, source: str) -> None:
|
|
nonlocal recorded
|
|
if not src or not tgt or (src, tgt) in seen:
|
|
return
|
|
seen.add((src, tgt))
|
|
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
|
recorded += 1
|
|
|
|
for e in edges_raw:
|
|
_add(e.get("source"), e.get("target"), _PROXMOX_GUEST_SOURCE)
|
|
for src, tgt in cluster_pairs:
|
|
_add(src, tgt, _PROXMOX_CLUSTER_SOURCE)
|
|
|
|
return recorded
|
|
|
|
|
|
@router.get("/config", response_model=ProxmoxConfig)
|
|
async def get_proxmox_config(_: str = Depends(get_current_user)) -> ProxmoxConfig:
|
|
"""Return non-secret Proxmox config. Never includes the token — only whether
|
|
one is configured on the server."""
|
|
return ProxmoxConfig(
|
|
host=settings.proxmox_host,
|
|
port=settings.proxmox_port,
|
|
verify_tls=settings.proxmox_verify_tls,
|
|
sync_enabled=settings.proxmox_sync_enabled,
|
|
sync_interval=settings.proxmox_sync_interval,
|
|
token_configured=bool(settings.proxmox_token_id and settings.proxmox_token_secret),
|
|
)
|
|
|
|
|
|
@router.post("/config", response_model=ProxmoxConfig)
|
|
async def save_proxmox_config(
|
|
payload: ProxmoxConfig,
|
|
_: str = Depends(get_current_user),
|
|
) -> ProxmoxConfig:
|
|
"""Persist non-secret Proxmox config and apply the auto-sync schedule live.
|
|
|
|
The token is NOT accepted here — it is env-only by design.
|
|
"""
|
|
if payload.sync_enabled and not (settings.proxmox_token_id and settings.proxmox_token_secret):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot enable auto-sync: no Proxmox API token configured on the server.",
|
|
)
|
|
try:
|
|
settings.proxmox_host = payload.host
|
|
settings.proxmox_port = payload.port
|
|
settings.proxmox_verify_tls = payload.verify_tls
|
|
settings.proxmox_sync_enabled = payload.sync_enabled
|
|
settings.proxmox_sync_interval = payload.sync_interval
|
|
settings.save_overrides()
|
|
set_proxmox_sync_enabled(payload.sync_enabled)
|
|
if payload.sync_enabled:
|
|
reschedule_proxmox_sync(payload.sync_interval)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
|
|
return await get_proxmox_config()
|