generated from erangel1/generic-template
initial commit. phase 1 complete
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"""Django admin registration for all core models."""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Edge, HeartbeatLog, Network, Node, WikiPage
|
||||
|
||||
|
||||
@admin.register(Node)
|
||||
class NodeAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("label", "node_type", "status", "ip_address", "discovery_source", "updated_at")
|
||||
list_filter = ("node_type", "status", "discovery_source")
|
||||
search_fields = ("label", "ip_address", "mac_address", "external_id")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
ordering = ("label",)
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(Edge)
|
||||
class EdgeAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("source", "target", "edge_type", "weight", "created_at")
|
||||
list_filter = ("edge_type",)
|
||||
raw_id_fields = ("source", "target")
|
||||
readonly_fields = ("id", "created_at")
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(Network)
|
||||
class NetworkAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("name", "cidr", "vlan_id", "gateway", "created_at")
|
||||
search_fields = ("name", "cidr")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(WikiPage)
|
||||
class WikiPageAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("node", "last_edited_by", "updated_at")
|
||||
raw_id_fields = ("node", "last_edited_by")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(HeartbeatLog)
|
||||
class HeartbeatLogAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("node", "status", "check_type", "response_time_ms", "timestamp")
|
||||
list_filter = ("status", "check_type")
|
||||
raw_id_fields = ("node",)
|
||||
readonly_fields = ("id", "timestamp")
|
||||
date_hierarchy = "timestamp"
|
||||
list_per_page = 100
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Core app configuration.
|
||||
|
||||
Connects a post_migrate signal to enable PostgreSQL Row-Level Security
|
||||
on the core_node table. The policy is permissive for now (allow all);
|
||||
Phase 2+ can tighten it for multi-user support.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "apps.core"
|
||||
verbose_name = "Infrastructure Core"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
def ready(self) -> None:
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
post_migrate.connect(_enable_node_rls, sender=self)
|
||||
|
||||
|
||||
def _enable_node_rls(sender: AppConfig, **kwargs: object) -> None:
|
||||
"""Enable PostgreSQL RLS on the node table after migrations complete."""
|
||||
from django.db import connection
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Bail out early if core_node doesn't exist yet (fires before core migrations)
|
||||
cursor.execute(
|
||||
"SELECT EXISTS ("
|
||||
" SELECT 1 FROM information_schema.tables WHERE table_name = 'core_node'"
|
||||
");"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None or not row[0]:
|
||||
return
|
||||
cursor.execute("ALTER TABLE core_node ENABLE ROW LEVEL SECURITY;")
|
||||
cursor.execute(
|
||||
"DO $$ BEGIN "
|
||||
" IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'core_node' AND policyname = 'allow_all_for_now') THEN "
|
||||
" CREATE POLICY allow_all_for_now ON core_node USING (true) WITH CHECK (true); "
|
||||
" END IF; "
|
||||
"END $$;"
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
logger.debug("core_node table not yet available for RLS setup; will apply on next migrate.")
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Core graph models for LabGraph.
|
||||
|
||||
Graph structure:
|
||||
Node — vertices (any infrastructure component)
|
||||
Edge — directed relationships between nodes
|
||||
Network — IP network segments (VLANs/subnets)
|
||||
WikiPage — Markdown documentation anchored to a node
|
||||
HeartbeatLog — time-series liveness records per node
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import markdown as md
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Node(models.Model):
|
||||
"""
|
||||
Polymorphic infrastructure node — the vertex type in the LabGraph.
|
||||
|
||||
node_type drives rendering and determines what metadata keys are
|
||||
expected in the JSONField. Pydantic schemas in schemas.py validate
|
||||
metadata content at the API boundary before writes reach the ORM.
|
||||
"""
|
||||
|
||||
class NodeType(models.TextChoices):
|
||||
LOCATION = "location", "Physical Location"
|
||||
HARDWARE = "hardware", "Physical Hardware"
|
||||
HYPERVISOR = "hypervisor", "Hypervisor Host"
|
||||
VM = "vm", "Virtual Machine"
|
||||
CONTAINER = "container", "Container"
|
||||
APPLICATION = "application", "Application / Service"
|
||||
NETWORK_DEVICE = "network_device", "Network Device"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ONLINE = "online", "Online"
|
||||
OFFLINE = "offline", "Offline"
|
||||
DEGRADED = "degraded", "Degraded"
|
||||
UNKNOWN = "unknown", "Unknown"
|
||||
MAINTENANCE = "maintenance", "Maintenance"
|
||||
|
||||
class DiscoverySource(models.TextChoices):
|
||||
MANUAL = "manual", "Manually Added"
|
||||
PROXMOX = "proxmox", "Proxmox API"
|
||||
NMAP = "nmap", "Nmap Scan"
|
||||
SNMP = "snmp", "SNMP"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
label = models.CharField(max_length=255, db_index=True)
|
||||
node_type = models.CharField(
|
||||
max_length=30, choices=NodeType.choices, db_index=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.UNKNOWN,
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True, db_index=True)
|
||||
# Stored as colon-separated hex: "aa:bb:cc:dd:ee:ff"
|
||||
mac_address = models.CharField(max_length=17, null=True, blank=True)
|
||||
|
||||
# Rated power draw in watts — used by the power cost estimator (Phase 3)
|
||||
wattage = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
|
||||
|
||||
# Flexible per-type data; validated by Pydantic at the API boundary
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
discovery_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=DiscoverySource.choices,
|
||||
default=DiscoverySource.MANUAL,
|
||||
)
|
||||
# ID in the originating external system, e.g. Proxmox VMID or Docker container ID
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["label"]
|
||||
indexes = [
|
||||
models.Index(fields=["node_type", "status"]),
|
||||
models.Index(fields=["discovery_source", "external_id"]),
|
||||
]
|
||||
verbose_name = "Node"
|
||||
verbose_name_plural = "Nodes"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.label} ({self.get_node_type_display()})"
|
||||
|
||||
|
||||
class Edge(models.Model):
|
||||
"""
|
||||
Directed edge between two Nodes in the infrastructure graph.
|
||||
|
||||
Direction: source → target.
|
||||
Multiple edge types allow different relationship semantics to coexist
|
||||
(e.g. a VM has a parent_child edge to its hypervisor AND a network
|
||||
edge to a switch).
|
||||
"""
|
||||
|
||||
class EdgeType(models.TextChoices):
|
||||
PARENT_CHILD = "parent_child", "Parent / Child"
|
||||
NETWORK = "network", "Network Connection"
|
||||
DEPENDENCY = "dependency", "Software Dependency"
|
||||
PHYSICAL = "physical", "Physical Connection"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
source = models.ForeignKey(
|
||||
Node, on_delete=models.CASCADE, related_name="outgoing_edges"
|
||||
)
|
||||
target = models.ForeignKey(
|
||||
Node, on_delete=models.CASCADE, related_name="incoming_edges"
|
||||
)
|
||||
edge_type = models.CharField(
|
||||
max_length=20, choices=EdgeType.choices, db_index=True
|
||||
)
|
||||
|
||||
weight = models.FloatField(default=1.0)
|
||||
label = models.CharField(max_length=255, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("source", "target", "edge_type")]
|
||||
indexes = [models.Index(fields=["edge_type"])]
|
||||
verbose_name = "Edge"
|
||||
verbose_name_plural = "Edges"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.source.label} → {self.target.label} [{self.edge_type}]"
|
||||
|
||||
|
||||
class Network(models.Model):
|
||||
"""
|
||||
An IP network segment that nodes attach to.
|
||||
|
||||
Nodes reference Networks via metadata or a future ManyToMany through-model
|
||||
(Phase 2 IPAM). Networks are separate entities from Nodes so that VLAN
|
||||
and subnet data can be managed independently.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
vlan_id = models.PositiveSmallIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="802.1Q VLAN tag (1–4094)",
|
||||
)
|
||||
# CIDR notation, e.g. "192.168.10.0/24"
|
||||
cidr = models.CharField(max_length=43, db_index=True)
|
||||
gateway = models.GenericIPAddressField(null=True, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["cidr"]
|
||||
verbose_name = "Network"
|
||||
verbose_name_plural = "Networks"
|
||||
|
||||
def __str__(self) -> str:
|
||||
vlan_str = f" (VLAN {self.vlan_id})" if self.vlan_id else ""
|
||||
return f"{self.name} {self.cidr}{vlan_str}"
|
||||
|
||||
|
||||
class WikiPage(models.Model):
|
||||
"""
|
||||
Human-authored documentation anchored to a single Node.
|
||||
|
||||
Content is stored as raw Markdown. rendered_html() converts it to
|
||||
safe HTML at render time using the Markdown package.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
node = models.OneToOneField(
|
||||
Node, on_delete=models.CASCADE, related_name="wiki_page"
|
||||
)
|
||||
content = models.TextField(blank=True, help_text="Markdown content")
|
||||
last_edited_by = models.ForeignKey(
|
||||
"auth.User",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="wiki_edits",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Wiki Page"
|
||||
verbose_name_plural = "Wiki Pages"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Wiki: {self.node.label}"
|
||||
|
||||
def rendered_html(self) -> str:
|
||||
"""Convert Markdown content to safe HTML."""
|
||||
return md.markdown(
|
||||
self.content,
|
||||
extensions=["fenced_code", "tables", "nl2br"],
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatLog(models.Model):
|
||||
"""
|
||||
Time-series liveness record for a Node.
|
||||
|
||||
Written by tasks/heartbeat.py on every check cycle. High-volume —
|
||||
records accumulate fast. The maintenance task prunes rows older than
|
||||
30 days nightly.
|
||||
"""
|
||||
|
||||
class CheckType(models.TextChoices):
|
||||
ICMP = "icmp", "ICMP Ping"
|
||||
TCP = "tcp", "TCP Port Check"
|
||||
HTTP = "http", "HTTP/S Check"
|
||||
SNMP = "snmp", "SNMP Poll"
|
||||
|
||||
class HeartbeatStatus(models.TextChoices):
|
||||
UP = "up", "Up"
|
||||
DOWN = "down", "Down"
|
||||
TIMEOUT = "timeout", "Timeout"
|
||||
ERROR = "error", "Error"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
node = models.ForeignKey(
|
||||
Node, on_delete=models.CASCADE, related_name="heartbeat_logs"
|
||||
)
|
||||
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
status = models.CharField(
|
||||
max_length=10, choices=HeartbeatStatus.choices, db_index=True
|
||||
)
|
||||
check_type = models.CharField(max_length=10, choices=CheckType.choices)
|
||||
# Null on timeout or error — response never completed
|
||||
response_time_ms = models.PositiveIntegerField(null=True, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
indexes = [
|
||||
models.Index(fields=["node", "-timestamp"]),
|
||||
models.Index(fields=["status", "-timestamp"]),
|
||||
]
|
||||
verbose_name = "Heartbeat Log"
|
||||
verbose_name_plural = "Heartbeat Logs"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.node.label} [{self.status}] @ {self.timestamp:%Y-%m-%d %H:%M:%S}"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Pydantic v2 input validation schemas for the LabGraph API.
|
||||
|
||||
These schemas validate incoming request data at the API boundary BEFORE
|
||||
it reaches the Django ORM. A ValidationError here is converted to an
|
||||
HTTP 400 in the view layer (Phase 2).
|
||||
|
||||
Keeping validation here (not in DRF serializers) means the same schemas
|
||||
can be reused by Celery discovery tasks when upserting auto-discovered nodes.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
_MAC_PATTERN = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
|
||||
|
||||
VALID_NODE_TYPES = {
|
||||
"location", "hardware", "hypervisor", "vm",
|
||||
"container", "application", "network_device",
|
||||
}
|
||||
|
||||
VALID_EDGE_TYPES = {"parent_child", "network", "dependency", "physical"}
|
||||
|
||||
VALID_DISCOVERY_SOURCES = {"manual", "proxmox", "nmap", "snmp"}
|
||||
|
||||
|
||||
class NodeCreateSchema(BaseModel):
|
||||
"""Validates POST /api/v1/nodes/ request body."""
|
||||
|
||||
label: str = Field(min_length=1, max_length=255)
|
||||
node_type: str
|
||||
ip_address: str | None = None
|
||||
mac_address: str | None = None
|
||||
wattage: float | None = Field(default=None, gt=0)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
discovery_source: str = "manual"
|
||||
external_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("node_type")
|
||||
@classmethod
|
||||
def validate_node_type(cls, v: str) -> str:
|
||||
if v not in VALID_NODE_TYPES:
|
||||
raise ValueError(f"node_type must be one of: {sorted(VALID_NODE_TYPES)}")
|
||||
return v
|
||||
|
||||
@field_validator("ip_address")
|
||||
@classmethod
|
||||
def validate_ip_address(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address: {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("mac_address")
|
||||
@classmethod
|
||||
def validate_mac_address(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not _MAC_PATTERN.match(v):
|
||||
raise ValueError("MAC address must be in aa:bb:cc:dd:ee:ff format")
|
||||
return v.lower()
|
||||
|
||||
@field_validator("discovery_source")
|
||||
@classmethod
|
||||
def validate_discovery_source(cls, v: str) -> str:
|
||||
if v not in VALID_DISCOVERY_SOURCES:
|
||||
raise ValueError(f"discovery_source must be one of: {sorted(VALID_DISCOVERY_SOURCES)}")
|
||||
return v
|
||||
|
||||
|
||||
class NodeUpdateSchema(NodeCreateSchema):
|
||||
"""Validates PATCH /api/v1/nodes/<id>/ — all fields optional."""
|
||||
|
||||
label: str = Field(default=None, min_length=1, max_length=255) # type: ignore[assignment]
|
||||
node_type: str | None = None # type: ignore[assignment]
|
||||
discovery_source: str = "manual"
|
||||
|
||||
|
||||
class EdgeCreateSchema(BaseModel):
|
||||
"""Validates POST /api/v1/edges/ request body."""
|
||||
|
||||
source: uuid.UUID
|
||||
target: uuid.UUID
|
||||
edge_type: str
|
||||
weight: float = Field(default=1.0, gt=0)
|
||||
label: str = Field(default="", max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("edge_type")
|
||||
@classmethod
|
||||
def validate_edge_type(cls, v: str) -> str:
|
||||
if v not in VALID_EDGE_TYPES:
|
||||
raise ValueError(f"edge_type must be one of: {sorted(VALID_EDGE_TYPES)}")
|
||||
return v
|
||||
|
||||
@field_validator("target")
|
||||
@classmethod
|
||||
def source_and_target_differ(cls, v: uuid.UUID, info: Any) -> uuid.UUID:
|
||||
if "source" in info.data and v == info.data["source"]:
|
||||
raise ValueError("source and target must be different nodes")
|
||||
return v
|
||||
|
||||
|
||||
class NetworkCreateSchema(BaseModel):
|
||||
"""Validates POST /api/v1/networks/ request body."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
cidr: str
|
||||
vlan_id: int | None = Field(default=None, ge=1, le=4094)
|
||||
gateway: str | None = None
|
||||
description: str = ""
|
||||
|
||||
@field_validator("cidr")
|
||||
@classmethod
|
||||
def validate_cidr(cls, v: str) -> str:
|
||||
try:
|
||||
ipaddress.ip_network(v, strict=False)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR notation: {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("gateway")
|
||||
@classmethod
|
||||
def validate_gateway(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid gateway IP address: {v!r}")
|
||||
return v
|
||||
|
||||
|
||||
class WikiPageUpdateSchema(BaseModel):
|
||||
"""Validates PATCH /api/v1/wiki/<node_id>/ request body."""
|
||||
|
||||
content: str = ""
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import include, path
|
||||
|
||||
from .views import router
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Core API ViewSets — Phase 1 stub.
|
||||
|
||||
The DRF router is registered here; viewsets will be added in Phase 2
|
||||
alongside full CRUD and graph traversal endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
|
||||
# Phase 2 additions:
|
||||
# from .viewsets import NodeViewSet, EdgeViewSet, NetworkViewSet, WikiPageViewSet
|
||||
# router.register("nodes", NodeViewSet, basename="node")
|
||||
# router.register("edges", EdgeViewSet, basename="edge")
|
||||
# router.register("networks", NetworkViewSet, basename="network")
|
||||
# router.register("wiki", WikiPageViewSet, basename="wiki")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HealthConfig(AppConfig):
|
||||
name = "apps.health"
|
||||
verbose_name = "Health Check"
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import HealthCheckView
|
||||
|
||||
urlpatterns = [
|
||||
path("", HealthCheckView.as_view(), name="health-check"),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Health check endpoint required by the docker-compose web service healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fs http://localhost:8000/api/health/ || exit 1"]
|
||||
|
||||
Does NOT require authentication — the docker daemon calls this, not users.
|
||||
Returns HTTP 200 when all services are healthy, HTTP 503 on any failure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import OperationalError, connection
|
||||
from django.http import JsonResponse
|
||||
from django.views import View
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
class HealthCheckView(View):
|
||||
"""
|
||||
Synchronous health check for web, database, and Redis.
|
||||
|
||||
Response body:
|
||||
{
|
||||
"status": "ok" | "degraded",
|
||||
"version": "1.0.0",
|
||||
"services": {
|
||||
"database": "ok" | "error",
|
||||
"redis": "ok" | "error"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def get(self, request) -> JsonResponse: # type: ignore[override]
|
||||
db_status = _check_database()
|
||||
redis_status = _check_redis()
|
||||
|
||||
all_ok = db_status == "ok" and redis_status == "ok"
|
||||
http_status = 200 if all_ok else 503
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "ok" if all_ok else "degraded",
|
||||
"version": VERSION,
|
||||
"services": {
|
||||
"database": db_status,
|
||||
"redis": redis_status,
|
||||
},
|
||||
},
|
||||
status=http_status,
|
||||
)
|
||||
|
||||
|
||||
def _check_database() -> str:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
return "ok"
|
||||
except OperationalError:
|
||||
logger.exception("Database health check failed")
|
||||
return "error"
|
||||
|
||||
|
||||
def _check_redis() -> str:
|
||||
try:
|
||||
cache.set("_health_check", "1", timeout=5)
|
||||
if cache.get("_health_check") != "1":
|
||||
raise RuntimeError("Redis round-trip failed")
|
||||
return "ok"
|
||||
except Exception:
|
||||
logger.exception("Redis health check failed")
|
||||
return "error"
|
||||
Reference in New Issue
Block a user