feat: import hosts/VMs/LXC from Proxmox VE with optional auto-sync

Add a Proxmox VE importer that reads the /api2/json REST API with a read-only
API token and drops hosts (proxmox), VMs (vm) and LXC containers (lxc) onto the
canvas as typed nodes with run state and hardware specs (vCPU/RAM/disk).

- Backend: proxmox_service (httpx) + proxmox routes (test-connection, import,
  import-pending, config). Two-tier dedupe — merge onto an existing scanned node
  by IP, else synthetic pve-{host}-{vmid} identity. Update-in-place, never
  deletes. Host->guest rendered as a 'virtual' edge via the pending-link flow.
- Security: token is env-only (PROXMOX_TOKEN_*), never written to disk by the
  app, never returned by any endpoint; errors are credential-sanitized.
- Auto-sync: optional scheduled re-import into pending (APScheduler job).
- PendingDevice.properties carries specs through approve (+ migration).
- Frontend: ProxmoxImportModal, sidebar entry, pending inventory source filter,
  Settings auto-sync section, proxmoxApi client.
- Docs: docs/proxmox-import.md, README + FEATURES sections, .env.example keys.
- Tests: backend service/router/scheduler, frontend modal/client/pending.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-05 18:58:12 +02:00
parent 1d40d70150
commit ab36ba6f81
29 changed files with 2349 additions and 29 deletions
+31
View File
@@ -80,6 +80,19 @@ class Settings(BaseSettings):
# Leave empty to keep the feature disabled (default).
homepage_api_key: str = ""
# Proxmox VE import.
# Token = a real credential → env/.env ONLY, never persisted by the app to
# scan_config.json and never returned by the API. token_id is
# 'user@realm!tokenname'; use a read-only PVEAuditor role.
proxmox_token_id: str = ""
proxmox_token_secret: str = ""
# Non-secret connection + auto-sync config (persisted via save_overrides).
proxmox_host: str = ""
proxmox_port: int = 8006
proxmox_verify_tls: bool = True
proxmox_sync_enabled: bool = False
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
@@ -108,6 +121,17 @@ class Settings(BaseSettings):
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
if "scanner_http_verify_tls" in data:
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
# Proxmox non-secret config (token stays env-only, never here).
if "proxmox_host" in data:
self.proxmox_host = str(data["proxmox_host"])
if "proxmox_port" in data:
self.proxmox_port = int(data["proxmox_port"])
if "proxmox_verify_tls" in data:
self.proxmox_verify_tls = bool(data["proxmox_verify_tls"])
if "proxmox_sync_enabled" in data:
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
if "proxmox_sync_interval" in data:
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
except Exception:
pass
@@ -122,6 +146,13 @@ class Settings(BaseSettings):
"scanner_http_ranges": self.scanner_http_ranges,
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
"scanner_http_verify_tls": self.scanner_http_verify_tls,
# Proxmox: only non-secret config. Token fields are intentionally
# excluded — they must never be written to disk by the app.
"proxmox_host": self.proxmox_host,
"proxmox_port": self.proxmox_port,
"proxmox_verify_tls": self.proxmox_verify_tls,
"proxmox_sync_enabled": self.proxmox_sync_enabled,
"proxmox_sync_interval": self.proxmox_sync_interval,
}))
+67
View File
@@ -106,6 +106,35 @@ async def _run_service_checks() -> None:
logger.error("Service checks failed for node %s: %s", node_id, exc)
async def _run_proxmox_sync() -> None:
"""Fetch the Proxmox inventory and upsert it into pending (auto-sync)."""
if not settings.proxmox_sync_enabled:
return
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
logger.warning("Proxmox auto-sync enabled but host/token not configured — skipping")
return
# Lazy import to avoid a circular import at module load.
from app.api.routes.proxmox import _persist_pending_import
from app.services.proxmox_service import fetch_proxmox_inventory
try:
nodes_raw, edges_raw = await fetch_proxmox_inventory(
host=settings.proxmox_host,
port=settings.proxmox_port,
token_id=settings.proxmox_token_id,
token_secret=settings.proxmox_token_secret,
verify_tls=settings.proxmox_verify_tls,
)
async with AsyncSessionLocal() as db:
result = await _persist_pending_import(db, nodes_raw, edges_raw)
logger.info(
"Proxmox auto-sync: %d devices (%d new, %d updated)",
result.device_count, result.pending_created, result.pending_updated,
)
except Exception as exc:
logger.error("Proxmox auto-sync failed: %s", exc)
def _add_service_check_job() -> None:
scheduler.add_job(
_run_service_checks,
@@ -117,6 +146,17 @@ def _add_service_check_job() -> None:
)
def _add_proxmox_sync_job() -> None:
scheduler.add_job(
_run_proxmox_sync,
"interval",
seconds=settings.proxmox_sync_interval,
id="proxmox_sync",
max_instances=1,
coalesce=True,
)
def start_scheduler() -> None:
global scheduler
if scheduler.running:
@@ -135,6 +175,8 @@ def start_scheduler() -> None:
)
if settings.service_check_enabled:
_add_service_check_job()
if settings.proxmox_sync_enabled:
_add_proxmox_sync_job()
scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
@@ -175,6 +217,31 @@ def set_service_checks_enabled(enabled: bool) -> None:
logger.info("Service checks disabled")
def reschedule_proxmox_sync(interval_seconds: int) -> None:
"""Update the Proxmox auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("proxmox_sync"):
scheduler.reschedule_job("proxmox_sync", trigger="interval", seconds=interval_seconds)
logger.info("Proxmox auto-sync rescheduled to every %ds", interval_seconds)
def set_proxmox_sync_enabled(enabled: bool) -> None:
"""Add or remove the Proxmox auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("proxmox_sync")
if enabled and not job:
_add_proxmox_sync_job()
logger.info("Proxmox auto-sync enabled — every %ds", settings.proxmox_sync_interval)
elif not enabled and job:
scheduler.remove_job("proxmox_sync")
logger.info("Proxmox auto-sync disabled")
def stop_scheduler() -> None:
if scheduler.running:
scheduler.shutdown(wait=False)