generated from erangel1/generic-template
initial commit. phase 1 complete
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
staticfiles/
|
||||
mediafiles/
|
||||
wiki_store/
|
||||
*.sqlite3
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
htmlcov/
|
||||
@@ -0,0 +1,64 @@
|
||||
# =============================================================================
|
||||
# LabGraph — Multi-stage Dockerfile
|
||||
# Stages: base → development → production
|
||||
# docker-compose uses target: development for all services
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# base — shared OS deps, non-root user, Python dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# curl: required by docker-compose web healthcheck (curl http://localhost:8000/api/health/)
|
||||
# nmap: required by python-nmap discovery tasks (Phase 2)
|
||||
# libpq-dev + build-essential: required to compile psycopg[binary]
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
curl \
|
||||
nmap \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --gid 1000 appuser && \
|
||||
useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# development — volume-mounted source, no CMD (docker-compose provides it)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS development
|
||||
|
||||
RUN pip install watchdog
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# production — source baked in, runs as non-root, gunicorn CMD
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "config.wsgi:application", \
|
||||
"--bind", "0.0.0.0:8000", \
|
||||
"--workers", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--threads", "2", \
|
||||
"--timeout", "120", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-"]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
ASGI config for LabGraph.
|
||||
|
||||
Kept for future WebSocket support (Phase 3+ real-time status updates).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Celery application for LabGraph.
|
||||
|
||||
Referenced by docker-compose as: celery -A config.celery <subcommand>
|
||||
|
||||
Queues:
|
||||
discovery — Proxmox API polling, nmap network scans
|
||||
heartbeat — Periodic ICMP/TCP liveness checks
|
||||
default — Maintenance, cleanup, housekeeping
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from kombu import Exchange, Queue
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
app = Celery("labgraph")
|
||||
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
app.autodiscover_tasks(["tasks"])
|
||||
|
||||
# Explicit queue definitions (mirrors --queues flag in docker-compose worker command)
|
||||
app.conf.task_queues = (
|
||||
Queue("default", Exchange("default"), routing_key="default"),
|
||||
Queue("discovery", Exchange("discovery"), routing_key="discovery"),
|
||||
Queue("heartbeat", Exchange("heartbeat"), routing_key="heartbeat"),
|
||||
)
|
||||
|
||||
app.conf.task_default_queue = "default"
|
||||
app.conf.task_default_exchange = "default"
|
||||
app.conf.task_default_routing_key = "default"
|
||||
|
||||
# Route tasks to queues by module prefix
|
||||
app.conf.task_routes = {
|
||||
"tasks.discovery.*": {"queue": "discovery"},
|
||||
"tasks.heartbeat.*": {"queue": "heartbeat"},
|
||||
"tasks.maintenance.*": {"queue": "default"},
|
||||
}
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
"heartbeat-all-nodes": {
|
||||
"task": "tasks.heartbeat.check_all_nodes",
|
||||
"schedule": 60.0,
|
||||
"queue": "heartbeat",
|
||||
# Drop task if not picked up before the next run fires
|
||||
"options": {"expires": 55},
|
||||
},
|
||||
"discovery-scan-networks": {
|
||||
"task": "tasks.discovery.scan_all_networks",
|
||||
"schedule": crontab(minute="*/15"),
|
||||
"queue": "discovery",
|
||||
},
|
||||
"maintenance-prune-heartbeat-logs": {
|
||||
"task": "tasks.maintenance.prune_old_heartbeat_logs",
|
||||
"schedule": crontab(hour=3, minute=0),
|
||||
"queue": "default",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Base settings shared across all LabGraph environments.
|
||||
|
||||
Never imported directly — always imported via development.py or production.py.
|
||||
All secrets are read from environment variables; missing required vars raise
|
||||
ImproperlyConfigured immediately on startup.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent # backend/
|
||||
|
||||
env = environ.Env(
|
||||
DEBUG=(bool, False),
|
||||
ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1"]),
|
||||
)
|
||||
|
||||
environ.Env.read_env(BASE_DIR.parent / ".env")
|
||||
|
||||
# =============================================================================
|
||||
# Security — no defaults for secrets; missing value raises ImproperlyConfigured
|
||||
# =============================================================================
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
|
||||
|
||||
# =============================================================================
|
||||
# Application definition
|
||||
# =============================================================================
|
||||
DJANGO_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"django_celery_beat",
|
||||
"django_celery_results",
|
||||
"drf_spectacular",
|
||||
"django_filters",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"apps.core",
|
||||
"apps.health",
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
# =============================================================================
|
||||
# Middleware — CorsMiddleware must be first
|
||||
# =============================================================================
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Database — parsed from DATABASE_URL env var by django-environ
|
||||
# =============================================================================
|
||||
DATABASES = {
|
||||
"default": env.db("DATABASE_URL")
|
||||
}
|
||||
|
||||
# Ensure Django uses UTC-aware datetimes in queries
|
||||
DATABASES["default"]["OPTIONS"] = {"options": "-c timezone=UTC"}
|
||||
|
||||
# =============================================================================
|
||||
# Cache — Redis
|
||||
# =============================================================================
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": env("REDIS_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Celery — all CELERY_* prefixed settings are picked up by
|
||||
# app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
# =============================================================================
|
||||
CELERY_BROKER_URL = env("CELERY_BROKER_URL")
|
||||
CELERY_RESULT_BACKEND = "django-db"
|
||||
CELERY_CACHE_BACKEND = "default"
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TIMEZONE = "UTC"
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 300
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 270
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 100
|
||||
|
||||
# =============================================================================
|
||||
# Django REST Framework
|
||||
# =============================================================================
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 50,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# drf-spectacular — OpenAPI schema
|
||||
# =============================================================================
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "LabGraph API",
|
||||
"DESCRIPTION": "HomeLab infrastructure documentation engine — graph-based inventory and monitoring.",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CORS
|
||||
# =============================================================================
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# =============================================================================
|
||||
# Static & media files
|
||||
# =============================================================================
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "mediafiles"
|
||||
|
||||
# =============================================================================
|
||||
# Internationalisation
|
||||
# =============================================================================
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# =============================================================================
|
||||
# Default primary key
|
||||
# =============================================================================
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# =============================================================================
|
||||
# Password validation
|
||||
# =============================================================================
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Development settings for LabGraph.
|
||||
|
||||
Inherits all base settings and adds:
|
||||
- DEBUG = True
|
||||
- Relaxed CORS (localhost:3000 allowed by default)
|
||||
- Console email backend
|
||||
- Verbose DEBUG-level logging for apps and Celery
|
||||
"""
|
||||
|
||||
from .base import * # noqa: F401, F403
|
||||
from .base import env
|
||||
|
||||
DEBUG = True
|
||||
|
||||
CORS_ALLOWED_ORIGINS = env.list(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
default=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
)
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "[{levelname}] {asctime} {name}: {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||
"celery": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"apps": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"tasks": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Production settings for LabGraph.
|
||||
|
||||
Inherits all base settings and adds:
|
||||
- DEBUG = False
|
||||
- Strict CORS (origins required from env, no default)
|
||||
- HSTS + secure cookies + SSL redirect
|
||||
- WhiteNoise compressed static files
|
||||
- JSON-formatted structured logging at WARNING level
|
||||
"""
|
||||
|
||||
from .base import * # noqa: F401, F403
|
||||
from .base import env
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Production CORS must be explicitly set — no default
|
||||
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS")
|
||||
|
||||
# Security hardening
|
||||
SECURE_HSTS_SECONDS = 31_536_000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True)
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
# WhiteNoise: compress + fingerprint static files for long-lived caching
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": "logging.Formatter",
|
||||
"fmt": '{"time": "%(asctime)s", "level": "%(levelname)s", "name": "%(name)s", "message": "%(message)s"}',
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "json",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "WARNING",
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console"], "level": "WARNING", "propagate": False},
|
||||
"celery": {"handlers": ["console"], "level": "WARNING", "propagate": False},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Root URL configuration for LabGraph.
|
||||
|
||||
Routes:
|
||||
/admin/ — Django admin interface
|
||||
/api/health/ — Health check (required by docker-compose healthcheck)
|
||||
/api/v1/ — DRF router (viewsets registered in Phase 2)
|
||||
/api/schema/ — OpenAPI schema (drf-spectacular)
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/health/", include("apps.health.urls")),
|
||||
path("api/v1/", include("apps.core.urls")),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
WSGI config for LabGraph.
|
||||
|
||||
Entry point used by Gunicorn in docker-compose:
|
||||
gunicorn config.wsgi:application
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
# Web Framework & API
|
||||
django>=5.0,<5.1
|
||||
djangorestframework
|
||||
django-cors-headers
|
||||
django-environ
|
||||
drf-spectacular
|
||||
django-filter
|
||||
|
||||
# Database & Task Queue
|
||||
psycopg[binary]
|
||||
celery[redis]
|
||||
django-celery-results
|
||||
django-celery-beat
|
||||
redis
|
||||
|
||||
# Data Validation & Logic
|
||||
pydantic>=2.0
|
||||
requests
|
||||
python-nmap
|
||||
icmplib
|
||||
Markdown
|
||||
|
||||
# Production Server
|
||||
gunicorn
|
||||
whitenoise
|
||||
|
||||
# Utilities
|
||||
python-dotenv
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
exec gunicorn config.wsgi:application \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers 4 \
|
||||
--worker-class gthread \
|
||||
--threads 2 \
|
||||
--timeout 120 \
|
||||
--access-logfile - \
|
||||
--error-logfile -
|
||||
@@ -0,0 +1,9 @@
|
||||
-- LabGraph PostgreSQL initialization script.
|
||||
-- Runs once when the postgres volume is first created (via docker-entrypoint-initdb.d).
|
||||
-- Must execute before Django migrations.
|
||||
|
||||
-- uuid_generate_v4() used as default for all UUIDField primary keys
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- GIN index support for fuzzy text search on Node.label
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Discovery task stubs — Phase 1 scaffolding.
|
||||
|
||||
Phase 2 implementations will:
|
||||
scan_all_networks — iterate Network queryset, run nmap per CIDR, upsert Nodes
|
||||
poll_proxmox — call Proxmox API, upsert VM/container Nodes via PROXMOX_URL/TOKEN env vars
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from config.celery import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="tasks.discovery.scan_all_networks", bind=True, max_retries=3)
|
||||
def scan_all_networks(self) -> dict: # type: ignore[type-arg]
|
||||
"""Stub: scan all configured Network CIDRs for live hosts."""
|
||||
logger.info("discovery.scan_all_networks: stub — Phase 2 not yet implemented")
|
||||
return {"status": "stub", "message": "Phase 2 not yet implemented"}
|
||||
|
||||
|
||||
@app.task(name="tasks.discovery.poll_proxmox", bind=True, max_retries=3)
|
||||
def poll_proxmox(self) -> dict: # type: ignore[type-arg]
|
||||
"""Stub: poll Proxmox API for VMs and containers."""
|
||||
logger.info("discovery.poll_proxmox: stub — Phase 2 not yet implemented")
|
||||
return {"status": "stub", "message": "Phase 2 not yet implemented"}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Heartbeat task stubs — Phase 1 scaffolding.
|
||||
|
||||
Phase 2 implementations will:
|
||||
check_all_nodes — query Node.objects.filter(ip_address__isnull=False),
|
||||
dispatch check_single_node subtasks via Celery group()
|
||||
check_single_node — icmplib.ping() for ICMP, socket.connect_ex() for TCP,
|
||||
httpx.get() for HTTP; write HeartbeatLog record
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from config.celery import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="tasks.heartbeat.check_all_nodes", bind=True)
|
||||
def check_all_nodes(self) -> dict: # type: ignore[type-arg]
|
||||
"""Stub: ICMP/TCP liveness check for all nodes that have an IP address."""
|
||||
logger.debug("heartbeat.check_all_nodes: stub — Phase 2 not yet implemented")
|
||||
return {"status": "stub", "checked": 0}
|
||||
|
||||
|
||||
@app.task(name="tasks.heartbeat.check_single_node", bind=True, max_retries=2)
|
||||
def check_single_node(self, node_id: str, check_type: str = "icmp") -> dict: # type: ignore[type-arg]
|
||||
"""Stub: check a single node and write a HeartbeatLog record."""
|
||||
logger.debug("heartbeat.check_single_node: stub for node_id=%s", node_id)
|
||||
return {"status": "stub", "node_id": node_id, "check_type": check_type}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Maintenance tasks — cleanup and housekeeping.
|
||||
|
||||
Scheduled via Celery Beat to run daily at 03:00 UTC.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from config.celery import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="tasks.maintenance.prune_old_heartbeat_logs", bind=True)
|
||||
def prune_old_heartbeat_logs(self, days: int = 30) -> dict: # type: ignore[type-arg]
|
||||
"""
|
||||
Delete HeartbeatLog records older than `days` days.
|
||||
|
||||
Returns the count of deleted rows for monitoring/alerting.
|
||||
Runs as a single bulk DELETE to avoid row-by-row overhead.
|
||||
"""
|
||||
from apps.core.models import HeartbeatLog
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
deleted_count, _ = HeartbeatLog.objects.filter(timestamp__lt=cutoff).delete()
|
||||
|
||||
logger.info(
|
||||
"maintenance.prune_old_heartbeat_logs: deleted %d records older than %d days",
|
||||
deleted_count,
|
||||
days,
|
||||
)
|
||||
return {"status": "ok", "deleted": deleted_count, "cutoff_days": days}
|
||||
Reference in New Issue
Block a user