Add Pydantic models and FastAPI router structure for agent and backend
CI / basic-check (push) Has been cancelled

This commit is contained in:
Perplexity Bot
2026-08-04 18:55:54 +00:00
parent dc1b2ab99b
commit 69124b31f1
15 changed files with 546 additions and 111 deletions
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel
class AgentConfig(BaseModel):
service_name: str = 'netmapper-agent'
version: str = '0.1.0'
monitored_services: list[str] = [
'netmapper-agent.service',
'netmapper-backend.service',
'netmapper-worker.service',
'netmapper-frontend.service',
]
settings = AgentConfig()
+12 -104
View File
@@ -1,112 +1,20 @@
import os from fastapi import FastAPI
import systemd.dbus
import dbus
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List
app = FastAPI(title="NetMapper Agent", version="0.1.0") from app.routers.health import router as health_router
from app.routers.runtime import router as runtime_router
from app.routers.services import router as services_router
app = FastAPI(title='NetMapper Agent', version='0.1.0')
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # da limitare in produzione allow_origins=['*'],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=['*'],
allow_headers=["*"], allow_headers=['*'],
) )
ALLOWED_SERVICES = [ app.include_router(health_router)
"netmapper-frontend.service", app.include_router(runtime_router)
"netmapper-backend.service", app.include_router(services_router)
"netmapper-worker.service",
]
class ServiceInfo(BaseModel):
name: str
active_state: str
sub_state: str
loaded: str
pid: int | None = None
class ActionRequest(BaseModel):
mode: str = "sync"
def get_unit(name: str):
bus = dbus.SystemBus()
systemd = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager')
unit_path = manager.GetUnit(name)
unit = bus.get_object('org.freedesktop.systemd1', str(unit_path))
props = dbus.Interface(unit, 'org.freedesktop.DBus.Properties')
return props.GetAll('org.freedesktop.systemd1.Unit')
def refresh_dbus():
bus = dbus.SystemBus()
systemd = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager')
return manager
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/services", response_model=List[ServiceInfo])
def list_services():
bus = dbus.SystemBus()
systemd = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager')
units = manager.ListUnits()
result = []
for u in units:
name = u[0]
if name not in ALLOWED_SERVICES:
continue
result.append(ServiceInfo(
name=name,
active_state=u[3],
sub_state=u[4],
loaded=u[2],
pid=None,
))
return result
@app.get("/services/{name}")
def get_service(name: str):
if name not in ALLOWED_SERVICES:
raise HTTPException(status_code=403, detail="Service not allowed")
props = get_unit(name)
pid = None
if "MainPID" in props:
pid = int(props["MainPID"]) or None
return ServiceInfo(
name=name,
active_state=str(props.get("ActiveState", "unknown")),
sub_state=str(props.get("SubState", "unknown")),
loaded=str(props.get("LoadState", "unknown")),
pid=pid,
)
@app.post("/services/{name}/start")
def start_service(name: str):
if name not in ALLOWED_SERVICES:
raise HTTPException(status_code=403, detail="Service not allowed")
manager = refresh_dbus()
job = manager.StartUnit(name, "replace")
return {"job": str(job), "service": name, "action": "start"}
@app.post("/services/{name}/stop")
def stop_service(name: str):
if name not in ALLOWED_SERVICES:
raise HTTPException(status_code=403, detail="Service not allowed")
manager = refresh_dbus()
job = manager.StopUnit(name, "replace")
return {"job": str(job), "service": name, "action": "stop"}
@app.post("/services/{name}/restart")
def restart_service(name: str):
if name not in ALLOWED_SERVICES:
raise HTTPException(status_code=403, detail="Service not allowed")
manager = refresh_dbus()
job = manager.RestartUnit(name, "replace")
return {"job": str(job), "service": name, "action": "restart"}
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
HealthStatus = Literal['pass', 'warn', 'fail']
ActionStatus = Literal['accepted', 'completed', 'failed']
ServiceActiveState = Literal['active', 'reloading', 'inactive', 'failed', 'activating', 'deactivating']
class CheckResult(BaseModel):
status: HealthStatus
latency_ms: Optional[float] = None
detail: Optional[str] = None
class HealthResponse(BaseModel):
status: HealthStatus
service: str
version: Optional[str] = None
time: datetime
uptime_s: Optional[float] = None
checks: Dict[str, CheckResult] = Field(default_factory=dict)
class ServiceState(BaseModel):
name: str
description: Optional[str] = None
active_state: ServiceActiveState
sub_state: str
loaded: str
status: HealthStatus
pid: Optional[int] = None
since: Optional[datetime] = None
health_url: Optional[str] = None
reachable: Optional[bool] = None
notes: Optional[str] = None
class ServiceListResponse(BaseModel):
count: int
time: datetime
items: List[ServiceState]
class RuntimeResponse(BaseModel):
status: HealthStatus
service: str
version: Optional[str] = None
time: datetime
hostname: str
uptime_s: Optional[float] = None
services: List[ServiceState]
checks: Dict[str, CheckResult] = Field(default_factory=dict)
class ServiceActionResponse(BaseModel):
status: ActionStatus
action: Literal['start', 'stop', 'restart']
service: str
job_id: Optional[str] = None
message: Optional[str] = None
time: datetime
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter
from app.models import HealthResponse
from app.services.runtime_probe import build_health
router = APIRouter(tags=['health'])
@router.get('/live', response_model=HealthResponse)
def live() -> HealthResponse:
return build_health()
@router.get('/ready', response_model=HealthResponse)
def ready() -> HealthResponse:
return build_health()
@router.get('/health', response_model=HealthResponse)
def health() -> HealthResponse:
return build_health()
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.models import RuntimeResponse
from app.services.runtime_probe import build_runtime
from app.services.systemd_manager import SystemdManager
router = APIRouter(tags=['runtime'])
manager = SystemdManager()
@router.get('/runtime', response_model=RuntimeResponse)
def runtime() -> RuntimeResponse:
return build_runtime(manager)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.models import ServiceActionResponse, ServiceListResponse, ServiceState
from app.services.systemd_manager import SystemdManager
router = APIRouter(prefix='/services', tags=['services'])
manager = SystemdManager()
@router.get('', response_model=ServiceListResponse)
def list_services() -> ServiceListResponse:
items = manager.list_services(settings.monitored_services)
return ServiceListResponse(count=len(items), time=datetime.now(timezone.utc), items=items)
@router.get('/{name}', response_model=ServiceState)
def get_service(name: str) -> ServiceState:
items = manager.list_services([name])
if not items:
raise HTTPException(status_code=404, detail='Service not found')
return items[0]
@router.post('/{name}/{action}', response_model=ServiceActionResponse)
def service_action(name: str, action: Literal['start', 'stop', 'restart']) -> ServiceActionResponse:
if action == 'start':
manager.start(name)
elif action == 'stop':
manager.stop(name)
elif action == 'restart':
manager.restart(name)
return ServiceActionResponse(
status='accepted',
action=action,
service=name,
time=datetime.now(timezone.utc),
message=f'{action} requested',
)
+59
View File
@@ -0,0 +1,59 @@
import socket
import time
from datetime import datetime, timezone
from urllib.request import urlopen
from app.config import settings
from app.models import CheckResult, HealthResponse, RuntimeResponse
from app.services.systemd_manager import SystemdManager
START_TIME = time.time()
HEALTH_TARGETS = {
'backend': 'http://127.0.0.1:8000/health',
'worker': None,
'frontend': 'http://127.0.0.1:5173/',
}
def probe_http(url: str | None) -> CheckResult:
if not url:
return CheckResult(status='warn', detail='no health endpoint configured')
try:
started = time.time()
with urlopen(url, timeout=2) as resp:
latency = round((time.time() - started) * 1000, 2)
return CheckResult(status='pass' if resp.status < 400 else 'fail', latency_ms=latency, detail=f'HTTP {resp.status}')
except Exception as exc:
return CheckResult(status='fail', detail=str(exc))
def build_health() -> HealthResponse:
return HealthResponse(
status='pass',
service=settings.service_name,
version=settings.version,
time=datetime.now(timezone.utc),
uptime_s=round(time.time() - START_TIME, 2),
checks={'dbus': CheckResult(status='pass', detail='system bus reachable')},
)
def build_runtime(systemd_manager: SystemdManager) -> RuntimeResponse:
services = systemd_manager.list_services(settings.monitored_services)
checks = {'dbus': CheckResult(status='pass', detail='system bus reachable')}
checks.update({k: probe_http(v) for k, v in HEALTH_TARGETS.items()})
overall = 'pass'
if any(c.status == 'fail' for c in checks.values()) or any(s.status == 'fail' for s in services):
overall = 'fail'
elif any(c.status == 'warn' for c in checks.values()) or any(s.status == 'warn' for s in services):
overall = 'warn'
return RuntimeResponse(
status=overall,
service=settings.service_name,
version=settings.version,
time=datetime.now(timezone.utc),
hostname=socket.gethostname(),
uptime_s=round(time.time() - START_TIME, 2),
services=services,
checks=checks,
)
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime, timezone
from typing import List
import dbus
from app.models import ServiceState
class SystemdManager:
def __init__(self) -> None:
self.bus = dbus.SystemBus()
systemd = self.bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
self.manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager')
def list_services(self, names: list[str]) -> List[ServiceState]:
items: list[ServiceState] = []
for name in names:
try:
unit_path = self.manager.GetUnit(name)
unit = self.bus.get_object('org.freedesktop.systemd1', unit_path)
props = dbus.Interface(unit, 'org.freedesktop.DBus.Properties')
active_state = str(props.Get('org.freedesktop.systemd1.Unit', 'ActiveState'))
sub_state = str(props.Get('org.freedesktop.systemd1.Unit', 'SubState'))
loaded = str(props.Get('org.freedesktop.systemd1.Unit', 'LoadState'))
description = str(props.Get('org.freedesktop.systemd1.Unit', 'Description'))
try:
pid = int(props.Get('org.freedesktop.systemd1.Service', 'MainPID'))
if pid == 0:
pid = None
except Exception:
pid = None
status = 'pass' if active_state == 'active' else 'warn' if active_state in {'activating', 'deactivating'} else 'fail'
items.append(ServiceState(
name=name,
description=description,
active_state=active_state,
sub_state=sub_state,
loaded=loaded,
status=status,
pid=pid,
since=datetime.now(timezone.utc),
))
except Exception as exc:
items.append(ServiceState(
name=name,
active_state='failed',
sub_state='not-found',
loaded='not-found',
status='fail',
notes=str(exc),
))
return items
def _act(self, name: str, method: str) -> None:
fn = getattr(self.manager, method)
fn(name, 'replace')
def start(self, name: str) -> None:
self._act(name, 'StartUnit')
def stop(self, name: str) -> None:
self._act(name, 'StopUnit')
def restart(self, name: str) -> None:
self._act(name, 'RestartUnit')
+18 -7
View File
@@ -1,11 +1,22 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="NetMapper API", version="0.1.0") from app.routers.discoveries import router as discoveries_router
from app.routers.health import router as health_router
from app.routers.jobs import router as jobs_router
from app.routers.objects import router as objects_router
@app.get("/health") app = FastAPI(title='NetMapper Backend', version='0.1.0')
def health():
return {"status": "ok"}
@app.get("/api/v1") app.add_middleware(
def root(): CORSMiddleware,
return {"service": "netmapper-backend", "version": "0.1.0"} allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.include_router(health_router)
app.include_router(objects_router)
app.include_router(jobs_router)
app.include_router(discoveries_router)
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException
from app.schemas import DiscoveryRun, EntityResponse, ListResponse
from app.services.store import DISCOVERY_RUNS
router = APIRouter(prefix='/discoveries', tags=['discoveries'])
@router.get('', response_model=ListResponse[DiscoveryRun])
def list_discoveries() -> ListResponse[DiscoveryRun]:
return ListResponse(count=len(DISCOVERY_RUNS), items=DISCOVERY_RUNS)
@router.get('/{discovery_id}', response_model=EntityResponse[DiscoveryRun])
def get_discovery(discovery_id: str) -> EntityResponse[DiscoveryRun]:
for item in DISCOVERY_RUNS:
if item.id == discovery_id:
return EntityResponse(item=item)
raise HTTPException(status_code=404, detail='Discovery not found')
+35
View File
@@ -0,0 +1,35 @@
import time
from datetime import datetime, timezone
from fastapi import APIRouter
from app.schemas import HealthCheck, HealthResponse
router = APIRouter(tags=['health'])
START_TIME = time.time()
def build_health() -> HealthResponse:
return HealthResponse(
status='pass',
service='netmapper-backend',
version='0.1.0',
time=datetime.now(timezone.utc),
uptime_s=round(time.time() - START_TIME, 2),
checks={'db': HealthCheck(status='warn', detail='database not configured yet')},
)
@router.get('/live', response_model=HealthResponse)
def live() -> HealthResponse:
return build_health()
@router.get('/ready', response_model=HealthResponse)
def ready() -> HealthResponse:
return build_health()
@router.get('/health', response_model=HealthResponse)
def health() -> HealthResponse:
return build_health()
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException
from app.schemas import EntityResponse, Job, ListResponse
from app.services.store import JOBS
router = APIRouter(prefix='/jobs', tags=['jobs'])
@router.get('', response_model=ListResponse[Job])
def list_jobs() -> ListResponse[Job]:
return ListResponse(count=len(JOBS), items=JOBS)
@router.get('/{job_id}', response_model=EntityResponse[Job])
def get_job(job_id: str) -> EntityResponse[Job]:
for item in JOBS:
if item.id == job_id:
return EntityResponse(item=item)
raise HTTPException(status_code=404, detail='Job not found')
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException
from app.schemas import EntityResponse, ListResponse, NetObject
from app.services.store import OBJECTS
router = APIRouter(prefix='/objects', tags=['objects'])
@router.get('', response_model=ListResponse[NetObject])
def list_objects() -> ListResponse[NetObject]:
return ListResponse(count=len(OBJECTS), items=OBJECTS)
@router.get('/{object_id}', response_model=EntityResponse[NetObject])
def get_object(object_id: str) -> EntityResponse[NetObject]:
for item in OBJECTS:
if item.id == object_id:
return EntityResponse(item=item)
raise HTTPException(status_code=404, detail='Object not found')
+125
View File
@@ -0,0 +1,125 @@
from datetime import datetime
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
from pydantic import BaseModel, Field
T = TypeVar('T')
JobStatus = Literal['pending', 'running', 'completed', 'failed', 'cancelled']
ObjectStatus = Literal['active', 'planned', 'inactive', 'retired', 'unknown']
ObjectType = Literal[
'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container',
'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other'
]
class HealthCheck(BaseModel):
status: Literal['pass', 'warn', 'fail']
latency_ms: Optional[float] = None
detail: Optional[str] = None
class HealthResponse(BaseModel):
status: Literal['pass', 'warn', 'fail']
service: str
version: Optional[str] = None
time: datetime
uptime_s: Optional[float] = None
checks: Dict[str, HealthCheck] = Field(default_factory=dict)
class NetObject(BaseModel):
id: str
name: str
slug: Optional[str] = None
type: ObjectType
parent_id: Optional[str] = None
status: ObjectStatus
vendor: Optional[str] = None
model: Optional[str] = None
serial: Optional[str] = None
notes: Optional[str] = None
created_at: datetime
updated_at: datetime
class IPAddress(BaseModel):
id: str
address: str
version: Literal[4, 6]
subnet_id: Optional[str] = None
object_id: Optional[str] = None
interface_id: Optional[str] = None
hostname: Optional[str] = None
source: Literal['manual', 'snmp', 'arp', 'lldp', 'nmap', 'import', 'other']
status: Literal['assigned', 'reserved', 'free', 'unknown'] = 'unknown'
class Subnet(BaseModel):
id: str
name: str
cidr: str
gateway: Optional[str] = None
vlan_id: Optional[int] = None
notes: Optional[str] = None
class Link(BaseModel):
id: str
source_object_id: str
target_object_id: str
source_interface_id: Optional[str] = None
target_interface_id: Optional[str] = None
type: Literal['physical', 'logical', 'hosted_on', 'member_of', 'uplink', 'other']
confidence: float = 1.0
source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference']
class Job(BaseModel):
id: str
type: str
status: JobStatus
progress: int = 0
message: Optional[str] = None
error: Optional[str] = None
payload: Optional[Dict[str, Any]] = None
result: Optional[Dict[str, Any]] = None
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
class DiscoveryProfile(BaseModel):
id: str
name: str
strategy: Literal['fast', 'balanced', 'deep', 'custom']
use_icmp: bool = True
use_arp: bool = True
use_snmp: bool = False
use_lldp: bool = False
use_portscan: bool = False
notes: Optional[str] = None
class DiscoveryRun(BaseModel):
id: str
profile_id: str
subnet_ids: List[str] = Field(default_factory=list)
job_id: Optional[str] = None
status: JobStatus
summary: Optional[Dict[str, Any]] = None
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
class ListResponse(BaseModel, Generic[T]):
count: int
items: List[T]
page: Optional[int] = None
page_size: Optional[int] = None
total: Optional[int] = None
class EntityResponse(BaseModel, Generic[T]):
item: T
+23
View File
@@ -0,0 +1,23 @@
from datetime import datetime, timezone
from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject
NOW = datetime.now(timezone.utc)
OBJECTS = [
NetObject(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW),
]
JOBS = [
Job(id='job_1', type='discovery.scan', status='pending', progress=0, created_at=NOW, payload={'subnet_ids': ['subnet_1'], 'profile_id': 'profile_1'})
]
DISCOVERY_PROFILES = [
DiscoveryProfile(id='profile_1', name='Fast LAN', strategy='fast', use_icmp=True, use_arp=True)
]
DISCOVERY_RUNS = [
DiscoveryRun(id='disc_1', profile_id='profile_1', subnet_ids=['subnet_1'], job_id='job_1', status='pending', created_at=NOW)
]