initial commit. phase 1 complete

This commit is contained in:
2026-05-05 20:45:19 +02:00
parent d9c68313a0
commit 89e058ffac
20631 changed files with 3224610 additions and 43 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
__pycache__
*.pyc
*.pyo
*.pyd
.env
.env.*
!.env.example
staticfiles/
mediafiles/
wiki_store/
*.sqlite3
.pytest_cache/
.mypy_cache/
htmlcov/
+64
View File
@@ -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", "-"]
View File
View File
+50
View File
@@ -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
+52
View File
@@ -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.")
+260
View File
@@ -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 (14094)",
)
# 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}"
+144
View File
@@ -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 = ""
+7
View File
@@ -0,0 +1,7 @@
from django.urls import include, path
from .views import router
urlpatterns = [
path("", include(router.urls)),
]
+17
View File
@@ -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")
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class HealthConfig(AppConfig):
name = "apps.health"
verbose_name = "Health Check"
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import HealthCheckView
urlpatterns = [
path("", HealthCheckView.as_view(), name="health-check"),
]
+74
View File
@@ -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"
View File
+13
View File
@@ -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()
+62
View File
@@ -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",
},
}
View File
+191
View File
@@ -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"},
]
+50
View File
@@ -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},
},
}
+57
View File
@@ -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},
},
}
+21
View File
@@ -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"),
]
+14
View File
@@ -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()
+23
View File
@@ -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()
+28
View File
@@ -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
+13
View File
@@ -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 -
+9
View File
@@ -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";
View File
+27
View File
@@ -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"}
+29
View File
@@ -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}
+35
View File
@@ -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}