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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user