Initial InfraPulse scaffold

This commit is contained in:
Keith Smith
2026-05-22 17:36:40 -06:00
commit a707186a5e
92 changed files with 6918 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""API routers."""
+106
View File
@@ -0,0 +1,106 @@
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import AlertRule, Incident, User
from app.schemas.core import AlertRuleCreate, AlertRuleRead, AlertRuleUpdate, IncidentRead
router = APIRouter(tags=["alerts"])
@router.get("/alerts/rules", response_model=list[AlertRuleRead])
def list_alert_rules(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[AlertRule]:
return list(db.scalars(select(AlertRule).order_by(AlertRule.name)).all())
@router.post("/alerts/rules", response_model=AlertRuleRead)
def create_alert_rule(
payload: AlertRuleCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AlertRule:
rule = AlertRule(**payload.model_dump())
db.add(rule)
db.commit()
db.refresh(rule)
return rule
@router.patch("/alerts/rules/{rule_id}", response_model=AlertRuleRead)
def update_alert_rule(
rule_id: int,
payload: AlertRuleUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AlertRule:
rule = db.get(AlertRule, rule_id)
if rule is None:
raise HTTPException(status_code=404, detail="Alert rule not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(rule, field, value)
db.commit()
db.refresh(rule)
return rule
@router.delete("/alerts/rules/{rule_id}", status_code=204)
def delete_alert_rule(
rule_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
rule = db.get(AlertRule, rule_id)
if rule is None:
raise HTTPException(status_code=404, detail="Alert rule not found")
db.delete(rule)
db.commit()
@router.get("/incidents", response_model=list[IncidentRead])
def list_incidents(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Incident]:
return list(db.scalars(select(Incident).order_by(Incident.opened_at.desc())).all())
@router.get("/incidents/{incident_id}", response_model=IncidentRead)
def get_incident(incident_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return incident
@router.post("/incidents/{incident_id}/acknowledge", response_model=IncidentRead)
def acknowledge_incident(
incident_id: int,
_: User = Depends(require_role("operator")),
db: Session = Depends(get_db),
) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
incident.acknowledged_at = datetime.now(UTC)
db.commit()
db.refresh(incident)
return incident
@router.post("/incidents/{incident_id}/silence", response_model=IncidentRead)
def silence_incident(
incident_id: int,
minutes: int = 60,
_: User = Depends(require_role("operator")),
db: Session = Depends(get_db),
) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
incident.silenced_until = datetime.now(UTC) + timedelta(minutes=minutes)
db.commit()
db.refresh(incident)
return incident
+90
View File
@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import Asset, User
from app.schemas.core import AssetCreate, AssetRead, AssetUpdate
router = APIRouter(prefix="/assets", tags=["assets"])
def _asset_to_read(asset: Asset) -> AssetRead:
return AssetRead(
id=asset.id,
name=asset.name,
asset_type=asset.asset_type,
address=asset.address,
status=asset.status,
metadata=asset.extra,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
@router.get("", response_model=list[AssetRead])
def list_assets(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[AssetRead]:
assets = db.scalars(select(Asset).order_by(Asset.name)).all()
return [_asset_to_read(asset) for asset in assets]
@router.post("", response_model=AssetRead)
def create_asset(
payload: AssetCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AssetRead:
asset = Asset(
name=payload.name,
asset_type=payload.asset_type,
address=payload.address,
extra=payload.metadata,
)
db.add(asset)
db.commit()
db.refresh(asset)
return _asset_to_read(asset)
@router.get("/{asset_id}", response_model=AssetRead)
def get_asset(asset_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> AssetRead:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
return _asset_to_read(asset)
@router.patch("/{asset_id}", response_model=AssetRead)
def update_asset(
asset_id: int,
payload: AssetUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AssetRead:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
changes = payload.model_dump(exclude_unset=True)
if "metadata" in changes:
asset.extra = changes.pop("metadata")
for field, value in changes.items():
setattr(asset, field, value)
db.commit()
db.refresh(asset)
return _asset_to_read(asset)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
db.delete(asset)
db.commit()
+32
View File
@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.auth.security import create_access_token, verify_password
from app.db.session import get_db
from app.models import User
from app.schemas.auth import TokenResponse, UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)) -> TokenResponse:
user = db.scalar(select(User).where(User.email == form.username))
if user is None or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
return TokenResponse(access_token=create_access_token(user.email))
@router.post("/logout")
def logout() -> dict[str, str]:
return {"status": "ok"}
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(get_current_user)) -> User:
return user
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "service": "infrapulse-backend"}
+156
View File
@@ -0,0 +1,156 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import AlertRule, Asset, CheckResult, Incident, Monitor, User
from app.schemas.core import CheckResultRead, MonitorCreate, MonitorRead, MonitorUpdate, WebsiteMonitorCreate
router = APIRouter(prefix="/monitors", tags=["monitors"])
@router.get("", response_model=list[MonitorRead])
def list_monitors(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Monitor]:
return list(db.scalars(select(Monitor).order_by(Monitor.name)).all())
@router.post("", response_model=MonitorRead)
def create_monitor(
payload: MonitorCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
monitor = Monitor(**payload.model_dump())
db.add(monitor)
db.commit()
db.refresh(monitor)
return monitor
@router.post("/website", response_model=MonitorRead)
def create_website_monitor(
payload: WebsiteMonitorCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
asset_id: int | None = None
if payload.create_asset:
asset = Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={})
db.add(asset)
db.flush()
asset_id = asset.id
monitor = Monitor(
asset_id=asset_id,
name=payload.name,
monitor_type="http",
target=payload.url,
config={
"expected_status": payload.expected_status,
"expected_text": payload.expected_text,
"unexpected_text": payload.unexpected_text,
"timeout_seconds": payload.timeout_seconds,
},
interval_seconds=payload.interval_seconds,
status="unknown",
)
db.add(monitor)
db.flush()
if payload.alert_enabled:
db.add(
AlertRule(
monitor_id=monitor.id,
name=f"{payload.name} website failure",
severity=payload.alert_severity,
condition={"type": "status_not_up"},
failure_threshold=payload.failure_threshold,
cooldown_seconds=300,
is_enabled=True,
)
)
db.commit()
db.refresh(monitor)
return monitor
@router.get("/{monitor_id}", response_model=MonitorRead)
def get_monitor(monitor_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Monitor:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
return monitor
@router.patch("/{monitor_id}", response_model=MonitorRead)
def update_monitor(
monitor_id: int,
payload: MonitorUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(monitor, field, value)
db.commit()
db.refresh(monitor)
return monitor
@router.delete("/{monitor_id}", status_code=204)
def delete_monitor(
monitor_id: int,
cleanup_orphan_website_asset: bool = True,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
asset_id = monitor.asset_id
now = datetime.now(UTC)
open_incidents = db.scalars(select(Incident).where(Incident.monitor_id == monitor_id, Incident.status == "open")).all()
for incident in open_incidents:
incident.status = "resolved"
incident.resolved_at = now
incident.details = {**(incident.details or {}), "resolution_reason": "monitor_deleted"}
db.delete(monitor)
db.flush()
if cleanup_orphan_website_asset and asset_id is not None:
remaining = db.scalar(select(func.count(Monitor.id)).where(Monitor.asset_id == asset_id))
asset = db.get(Asset, asset_id)
if remaining == 0 and asset is not None and asset.asset_type == "website":
db.delete(asset)
db.commit()
@router.get("/{monitor_id}/results", response_model=list[CheckResultRead])
def list_monitor_results(
monitor_id: int,
limit: int = 20,
_: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[CheckResult]:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
return list(
db.scalars(
select(CheckResult)
.where(CheckResult.monitor_id == monitor_id)
.order_by(CheckResult.observed_at.desc())
.limit(min(limit, 100))
).all()
)
+117
View File
@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.core.secrets import decrypt_secret, encrypt_secret
from app.db.session import get_db
from app.models import NotificationChannel, User
from app.schemas.core import NotificationChannelCreate, NotificationChannelRead, NotificationChannelUpdate
router = APIRouter(prefix="/notifications/channels", tags=["notifications"])
def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead:
settings = dict(channel.settings or {})
settings.setdefault("username", "InfraPulse")
return NotificationChannelRead(
id=channel.id,
name=channel.name,
channel_type=channel.channel_type,
settings=settings,
has_secret=bool(channel.encrypted_secret),
is_enabled=channel.is_enabled,
created_at=channel.created_at,
updated_at=channel.updated_at,
)
@router.get("", response_model=list[NotificationChannelRead])
def list_channels(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[NotificationChannelRead]:
channels = db.scalars(select(NotificationChannel).order_by(NotificationChannel.name)).all()
return [_channel_to_read(channel) for channel in channels]
@router.post("", response_model=NotificationChannelRead)
def create_channel(
payload: NotificationChannelCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel_settings = dict(payload.settings or {})
channel_settings.setdefault("username", "InfraPulse")
channel = NotificationChannel(
name=payload.name,
channel_type=payload.channel_type,
settings=channel_settings,
encrypted_secret=encrypt_secret(payload.secret),
is_enabled=payload.is_enabled,
)
db.add(channel)
db.commit()
db.refresh(channel)
return _channel_to_read(channel)
@router.post("/{channel_id}/test")
def test_channel(
channel_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> dict[str, str]:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
url = decrypt_secret(channel.encrypted_secret)
if not url:
raise HTTPException(status_code=400, detail="Notification channel has no usable secret URL")
try:
response = httpx.post(
url,
json={
"username": (channel.settings or {}).get("username") or "InfraPulse",
"text": f"InfraPulse test notification for {channel.name}",
},
timeout=10,
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail="Notification test failed") from exc
return {"status": "sent", "message": "Notification test sent"}
@router.patch("/{channel_id}", response_model=NotificationChannelRead)
def update_channel(
channel_id: int,
payload: NotificationChannelUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
changes = payload.model_dump(exclude_unset=True)
secret = changes.pop("secret", None)
if secret is not None:
channel.encrypted_secret = encrypt_secret(secret)
for field, value in changes.items():
setattr(channel, field, value)
db.commit()
db.refresh(channel)
return _channel_to_read(channel)
@router.delete("/{channel_id}", status_code=204)
def delete_channel(
channel_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
db.delete(channel)
db.commit()