- models: DomainConfig, ServiceConfig, DiagnosticReport (single source of truth) - interfaces: DIP & ISP contracts for detector, generator, executor, doctor, and migrator - detector: Smart auto-detection for modular (config/wsgi.py), traditional, and flat layouts - service_generator: Systemd service generation with --umask 007 and security sandboxing - executor: Linux command execution with dry-run support - nginx_manager: HestiaCP template installation and validation - migrator: Automated migration and repair of existing deployments - doctor: Comprehensive post-deployment health check engine
185 lines
5.1 KiB
Python
185 lines
5.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Domain models and value objects for the HestiaCP Django-Gunicorn deployment engine.
|
|
|
|
These dataclasses serve as the **Single Source of Truth** for all path
|
|
conventions, service naming rules, and configuration parameters used across
|
|
every module. Centralising them here eliminates magic strings and satisfies
|
|
the DRY principle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enumerations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ProjectLayout(Enum):
|
|
"""Detected Django project directory layout."""
|
|
|
|
MODULAR = auto() # config/wsgi.py (clean / modern)
|
|
TRADITIONAL = auto() # <project>/wsgi.py (django-admin startproject)
|
|
FLAT = auto() # wsgi.py at project root (rare but valid)
|
|
UNKNOWN = auto()
|
|
|
|
|
|
class DatabaseEngine(Enum):
|
|
"""Database back-end detected in settings."""
|
|
|
|
SQLITE = auto()
|
|
POSTGRESQL = auto()
|
|
MYSQL = auto()
|
|
OTHER = auto()
|
|
UNKNOWN = auto()
|
|
|
|
|
|
class DiagnosticStatus(Enum):
|
|
"""Result status for a single diagnostic check."""
|
|
|
|
OK = auto()
|
|
WARNING = auto()
|
|
CRITICAL = auto()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path & naming conventions (Single Source of Truth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
HESTIA_TEMPLATE_DIR = Path("/usr/local/hestia/data/templates/web/nginx")
|
|
SYSTEMD_SERVICE_DIR = Path("/etc/systemd/system")
|
|
TEMPLATE_NAME = "django-gunicorn"
|
|
SOCKET_FILENAME = "gunicorn.sock"
|
|
DEFAULT_VENV_CANDIDATES = ("venv", ".venv", "env")
|
|
DEFAULT_STATIC_DIR = "staticfiles"
|
|
|
|
|
|
def build_home_dir(user: str) -> Path:
|
|
"""Return the HestiaCP home directory for *user*."""
|
|
return Path(f"/home/{user}")
|
|
|
|
|
|
def build_web_dir(user: str, domain: str) -> Path:
|
|
"""Return the web-root (``public_html``) for a domain."""
|
|
return build_home_dir(user) / "web" / domain / "public_html"
|
|
|
|
|
|
def build_socket_path(user: str, domain: str) -> Path:
|
|
"""Return the canonical unix-socket path."""
|
|
return build_web_dir(user, domain) / SOCKET_FILENAME
|
|
|
|
|
|
def build_service_name(domain: str) -> str:
|
|
"""Return the systemd service unit name (without ``.service``)."""
|
|
return f"gunicorn-{domain}"
|
|
|
|
|
|
def build_service_file(domain: str) -> Path:
|
|
"""Return the absolute path to the systemd unit file."""
|
|
return SYSTEMD_SERVICE_DIR / f"{build_service_name(domain)}.service"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core data-transfer objects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class DomainConfig:
|
|
"""Immutable value object describing a HestiaCP domain deployment target."""
|
|
|
|
domain: str
|
|
user: str
|
|
|
|
@property
|
|
def home_dir(self) -> Path:
|
|
return build_home_dir(self.user)
|
|
|
|
@property
|
|
def web_dir(self) -> Path:
|
|
return build_web_dir(self.user, self.domain)
|
|
|
|
@property
|
|
def socket_path(self) -> Path:
|
|
return build_socket_path(self.user, self.domain)
|
|
|
|
@property
|
|
def service_name(self) -> str:
|
|
return build_service_name(self.domain)
|
|
|
|
@property
|
|
def service_file(self) -> Path:
|
|
return build_service_file(self.domain)
|
|
|
|
|
|
@dataclass
|
|
class DjangoProjectInfo:
|
|
"""Result of inspecting the Django project inside ``public_html``."""
|
|
|
|
layout: ProjectLayout = ProjectLayout.UNKNOWN
|
|
wsgi_module: str = ""
|
|
venv_path: Optional[Path] = None
|
|
settings_module: str = ""
|
|
static_root_name: str = DEFAULT_STATIC_DIR
|
|
database_engine: DatabaseEngine = DatabaseEngine.UNKNOWN
|
|
has_dot_env: bool = False
|
|
has_requirements: bool = False
|
|
errors: List[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
return self.layout != ProjectLayout.UNKNOWN and bool(self.wsgi_module)
|
|
|
|
|
|
@dataclass
|
|
class ServiceConfig:
|
|
"""All parameters needed to render a systemd unit file."""
|
|
|
|
domain_config: DomainConfig
|
|
project_info: DjangoProjectInfo
|
|
workers: int = 0
|
|
threads: int = 2
|
|
umask: str = "007"
|
|
log_level: str = "info"
|
|
restart_sec: int = 3
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.workers <= 0:
|
|
cpu = os.cpu_count() or 1
|
|
self.workers = (2 * cpu) + 1
|
|
|
|
|
|
@dataclass
|
|
class DiagnosticCheck:
|
|
"""A single diagnostic check result."""
|
|
|
|
name: str
|
|
status: DiagnosticStatus
|
|
message: str
|
|
suggestion: str = ""
|
|
|
|
|
|
@dataclass
|
|
class DiagnosticReport:
|
|
"""Collection of diagnostic checks for a deployment."""
|
|
|
|
domain_config: DomainConfig
|
|
checks: List[DiagnosticCheck] = field(default_factory=list)
|
|
|
|
@property
|
|
def is_healthy(self) -> bool:
|
|
return all(c.status == DiagnosticStatus.OK for c in self.checks)
|
|
|
|
@property
|
|
def critical_count(self) -> int:
|
|
return sum(1 for c in self.checks if c.status == DiagnosticStatus.CRITICAL)
|
|
|
|
@property
|
|
def warning_count(self) -> int:
|
|
return sum(1 for c in self.checks if c.status == DiagnosticStatus.WARNING)
|