9437a74147
Add a 'Re-sync now' button to the Proxmox auto-sync settings section that triggers an immediate inventory import (POST /proxmox/sync-now) using the server env config — the manual counterpart to scheduled auto-sync. Also fix a dual-source-of-truth bug: Proxmox connection config (host, port, token, verify_tls) is now env-only and never persisted to scan_config.json. Previously save_overrides() dumped host/port/verify alongside scan settings, so saving an unrelated setting wrote an empty proxmox_host that load_overrides then clobbered PROXMOX_HOST with on every boot. Only the auto-sync activation (sync_enabled + sync_interval) stays user-editable and persisted. ha-relevant: no
513 lines
19 KiB
Python
513 lines
19 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,
|
|
ProxmoxSyncConfig,
|
|
ProxmoxTestConnectionResponse,
|
|
)
|
|
from app.schemas.scan import ScanRunResponse
|
|
from app.services.discovery_sources import add_source
|
|
from app.services.mac_utils import normalize_mac
|
|
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
|
|
|
|
|
|
@router.post("/sync-now", response_model=ScanRunResponse)
|
|
async def sync_proxmox_now(
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> ScanRun:
|
|
"""Trigger an immediate Proxmox inventory sync using the server env config.
|
|
|
|
Same background flow as ``/import-pending`` but sources host + token from
|
|
``settings`` (env) rather than the request body — the manual counterpart to
|
|
the scheduled auto-sync job. Requires the env token to be configured.
|
|
"""
|
|
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot sync: no Proxmox host/token configured on the server.",
|
|
)
|
|
run = ScanRun(
|
|
status="running",
|
|
kind="proxmox",
|
|
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
|
|
)
|
|
db.add(run)
|
|
await db.commit()
|
|
await db.refresh(run)
|
|
background_tasks.add_task(
|
|
_background_proxmox_import,
|
|
run.id,
|
|
settings.proxmox_host,
|
|
settings.proxmox_port,
|
|
settings.proxmox_token_id,
|
|
settings.proxmox_token_secret,
|
|
settings.proxmox_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()
|
|
# Nudge the frontend to reload the inventory (same signal the IP scan
|
|
# emits) so imported/merged devices appear without a manual refresh.
|
|
from app.api.routes.status import broadcast_scan_update
|
|
await broadcast_scan_update(run_id=run_id, devices_found=result.device_count)
|
|
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")
|
|
mac = normalize_mac(n.get("mac"))
|
|
props = build_proxmox_properties(n)
|
|
|
|
# 1) Already on a canvas? Match by ieee OR ip OR mac (the cross-source
|
|
# dedup key — a stopped VM has no IP but its configured NIC MAC still
|
|
# matches an ARP-scanned node). Refresh in place: merge properties, adopt
|
|
# the pve identity onto a scanned node, backfill blank specs/hostname/mac.
|
|
# Do NOT stomp user-set type/status.
|
|
node_filter = [Node.ieee_address == ieee]
|
|
if ip:
|
|
node_filter.append(Node.ip == ip)
|
|
if mac:
|
|
node_filter.append(Node.mac == mac)
|
|
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
|
|
if mac and not en.mac:
|
|
en.mac = mac
|
|
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, mac, n, props, approved=True)
|
|
pending_updated += 1
|
|
continue
|
|
|
|
# 2) Not on canvas — upsert the pending inventory row.
|
|
pending = await _find_pending(db, ieee, ip, mac)
|
|
if pending is None:
|
|
db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
|
|
pending_created += 1
|
|
else:
|
|
_refresh_pending(pending, ieee, ip, mac, 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, mac: str | None
|
|
) -> PendingDevice | None:
|
|
filters = [PendingDevice.ieee_address == ieee]
|
|
if ip:
|
|
filters.append(PendingDevice.ip == ip)
|
|
if mac:
|
|
filters.append(PendingDevice.mac == mac)
|
|
return (
|
|
await db.execute(select(PendingDevice).where(or_(*filters)))
|
|
).scalars().first()
|
|
|
|
|
|
def _new_pending(
|
|
ieee: str,
|
|
ip: str | None,
|
|
mac: str | None,
|
|
n: dict[str, Any],
|
|
props: list[dict[str, Any]],
|
|
status: str,
|
|
) -> PendingDevice:
|
|
return PendingDevice(
|
|
ieee_address=ieee,
|
|
ip=ip,
|
|
mac=mac,
|
|
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_GUEST_SOURCE,
|
|
discovery_sources=[_PROXMOX_GUEST_SOURCE],
|
|
)
|
|
|
|
|
|
def _sources_after_merge(row: PendingDevice) -> list[str]:
|
|
"""Discovery sources for an inventory row after a Proxmox import merges in.
|
|
|
|
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
|
|
whether the row was originally a scanned device. Preserves the prior scan
|
|
origin — including legacy rows created before ``discovery_sources`` existed
|
|
(empty list) and possibly with a NULL ``discovery_source`` — so the IP tag
|
|
survives the merge. A row that carries an IP but was not itself a Proxmox
|
|
device (no ``pve-`` ieee) was found by a scan; keep an IP-scan source.
|
|
"""
|
|
sources = add_source(row.discovery_sources, row.discovery_source)
|
|
was_scanned = not (row.ieee_address or "").startswith("pve-")
|
|
if was_scanned and row.ip and not any(s in ("arp", "mdns") for s in sources):
|
|
sources = add_source(sources, "arp")
|
|
return add_source(sources, _PROXMOX_GUEST_SOURCE)
|
|
|
|
|
|
def _refresh_pending(
|
|
pending: PendingDevice,
|
|
ieee: str,
|
|
ip: str | None,
|
|
mac: str | None,
|
|
n: dict[str, Any],
|
|
props: list[dict[str, Any]],
|
|
) -> None:
|
|
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
|
|
pending.discovery_sources = _sources_after_merge(pending)
|
|
pending.ieee_address = pending.ieee_address or ieee
|
|
pending.ip = ip or pending.ip
|
|
pending.mac = pending.mac or mac
|
|
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,
|
|
mac: 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, mac)
|
|
if inv is None:
|
|
db.add(_new_pending(ieee, ip, mac, n, props, status="approved" if approved else "pending"))
|
|
else:
|
|
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
|
|
inv.discovery_sources = _sources_after_merge(inv)
|
|
inv.ieee_address = inv.ieee_address or ieee
|
|
inv.ip = ip or inv.ip
|
|
inv.mac = inv.mac or mac
|
|
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: ProxmoxSyncConfig,
|
|
_: str = Depends(get_current_user),
|
|
) -> ProxmoxConfig:
|
|
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
|
|
|
This is the ONLY Proxmox config the app writes. Connection settings
|
|
(host, port, token, verify_tls) are env-only and are never accepted or
|
|
persisted here — enabling auto-sync requires host + token already set in the
|
|
server env, since the scheduled job reads them from there.
|
|
"""
|
|
if payload.sync_enabled and not (
|
|
settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret
|
|
):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot enable auto-sync: no Proxmox host/token configured in the server env.",
|
|
)
|
|
try:
|
|
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()
|