feat(20-02): add parse-mib and test-profile API endpoints

- POST /snmp-profiles/parse-mib: upload MIB file, subprocess-call tod-mib-parser, return OID tree JSON
- POST /snmp-profiles/{id}/test: test profile connectivity via NATS discovery probe to poller
- New snmp_proxy service module following routeros_proxy.py lazy NATS pattern
- Pydantic schemas: MIBParseResponse, ProfileTestRequest, ProfileTestResponse, ProfileTestOIDResult
- MIB_PARSER_PATH config setting with /app/tod-mib-parser default
- MIB parse errors return 422, not 500; temp file cleanup in finally block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 20:21:08 -05:00
parent 77e9b680ae
commit 655f1eadae
7 changed files with 412 additions and 28 deletions

View File

@@ -7,33 +7,52 @@ Provides listing, creation, update, and deletion of SNMP device profiles.
System-shipped profiles (is_system=True, tenant_id IS NULL) are visible to
all tenants but cannot be modified or deleted.
Additional endpoints:
- POST /snmp-profiles/parse-mib: Upload a MIB file, parse via tod-mib-parser binary
- POST /snmp-profiles/{id}/test: Test a profile against a live device via NATS
RBAC:
- devices:read scope: GET (list, detail)
- operator+: POST, PUT (create, update tenant profiles)
- operator+: POST, PUT (create, update tenant profiles, parse-mib, test)
- tenant_admin+: DELETE (delete tenant profiles)
"""
import json
import logging
import shutil
import subprocess
import tempfile
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.rbac import require_operator_or_above, require_scope, require_tenant_admin_or_above
from app.middleware.tenant_context import CurrentUser, get_current_user
from app.routers.devices import _check_tenant_access
from app.schemas.snmp_profile import (
MIBParseResponse,
ProfileTestRequest,
ProfileTestResponse,
SNMPProfileCreate,
SNMPProfileDetailResponse,
SNMPProfileListResponse,
SNMPProfileResponse,
SNMPProfileUpdate,
)
from app.services import snmp_proxy
logger = logging.getLogger(__name__)
router = APIRouter(tags=["snmp-profiles"])
# Resolve MIB parser binary path: prefer settings, fall back to PATH lookup
MIB_PARSER_BINARY = shutil.which("tod-mib-parser") or settings.MIB_PARSER_PATH
# ---------------------------------------------------------------------------
# List profiles (system + tenant)
@@ -303,3 +322,179 @@ async def delete_profile(
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
)
await db.commit()
# ---------------------------------------------------------------------------
# Parse MIB file
# ---------------------------------------------------------------------------
@router.post(
"/tenants/{tenant_id}/snmp-profiles/parse-mib",
response_model=MIBParseResponse,
summary="Upload and parse a MIB file",
dependencies=[Depends(require_operator_or_above)],
)
async def parse_mib(
tenant_id: uuid.UUID,
file: UploadFile,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> MIBParseResponse:
"""Upload a MIB file and parse it using the tod-mib-parser binary.
The binary reads the MIB, extracts OID definitions, and returns a JSON
tree of nodes suitable for building an SNMP profile.
Returns 422 if the MIB file is invalid or the parser encounters an error.
"""
await _check_tenant_access(current_user, tenant_id, db)
tmp_path: str | None = None
try:
# Save uploaded file to a temporary path
with tempfile.NamedTemporaryFile(suffix=".mib", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
# Call the MIB parser binary
try:
result = subprocess.run(
[MIB_PARSER_BINARY, tmp_path],
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
logger.error("MIB parser binary not found at %s", MIB_PARSER_BINARY)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="MIB parser not available",
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="MIB parser timed out",
)
if result.returncode != 0:
error_msg = result.stderr.strip() or "MIB parser failed"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=error_msg,
)
# Parse the JSON output
try:
parsed = json.loads(result.stdout)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="MIB parser returned invalid JSON",
)
# Check for parser-level error in the output
if "error" in parsed and parsed["error"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=parsed["error"],
)
# Build response
nodes = parsed.get("nodes", [])
return MIBParseResponse(
module_name=parsed.get("module_name", file.filename or "unknown"),
nodes=nodes,
node_count=len(nodes),
)
finally:
# Clean up temporary file
if tmp_path:
import os
try:
os.unlink(tmp_path)
except OSError:
pass
# ---------------------------------------------------------------------------
# Test profile against live device
# ---------------------------------------------------------------------------
@router.post(
"/tenants/{tenant_id}/snmp-profiles/{profile_id}/test",
response_model=ProfileTestResponse,
summary="Test an SNMP profile against a live device",
dependencies=[Depends(require_operator_or_above)],
)
async def test_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
data: ProfileTestRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ProfileTestResponse:
"""Test connectivity to a device using the provided SNMP credentials.
Sends an SNMP discovery probe to the target device via the Go poller's
DiscoveryResponder (NATS request-reply to device.discover.snmp). Returns
the device's sysObjectID, sysDescr, and sysName if reachable.
This validates that the device is reachable with the given credentials,
which is the core requirement for PROF-05.
"""
await _check_tenant_access(current_user, tenant_id, db)
# Verify the profile exists and is visible to this tenant
result = await db.execute(
text("""
SELECT id, sys_object_id
FROM snmp_profiles
WHERE id = :profile_id
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
"""),
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
)
profile_row = result.mappings().first()
if not profile_row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="SNMP profile not found",
)
# Send discovery probe via NATS
discovery = await snmp_proxy.snmp_discover(
ip_address=data.ip_address,
snmp_port=data.snmp_port,
snmp_version=data.snmp_version,
community=data.community,
security_level=data.security_level,
username=data.username,
auth_protocol=data.auth_protocol,
auth_passphrase=data.auth_passphrase,
priv_protocol=data.priv_protocol,
priv_passphrase=data.priv_passphrase,
)
# Check for discovery error
if discovery.get("error"):
return ProfileTestResponse(
success=False,
error=discovery["error"],
)
# Build device info from discovery response
device_info = {
"sys_object_id": discovery.get("sys_object_id", ""),
"sys_descr": discovery.get("sys_descr", ""),
"sys_name": discovery.get("sys_name", ""),
}
return ProfileTestResponse(
success=True,
device_info=device_info,
)