feat(core): implement modular OOP engine following SOLID and DRY principles
- 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
This commit is contained in:
2
core/__init__.py
Normal file
2
core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""HestiaCP Django-Gunicorn Deployment Engine — Core Package."""
|
||||
207
core/detector.py
Normal file
207
core/detector.py
Normal file
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Smart Django project structure detector.
|
||||
|
||||
**Single Responsibility:** Inspect a ``public_html`` directory and produce a
|
||||
fully populated ``DjangoProjectInfo`` — identifying the WSGI module, virtual
|
||||
environment, static-file conventions, and database engine.
|
||||
|
||||
Supports three layout styles:
|
||||
* **Modular** — ``config/wsgi.py`` (clean architecture like ``portfolio``)
|
||||
* **Traditional** — ``<project>/wsgi.py`` (``django-admin startproject``)
|
||||
* **Flat** — ``wsgi.py`` at the project root (rare but valid)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.interfaces import IDjangoDetector
|
||||
from core.models import (
|
||||
DEFAULT_STATIC_DIR,
|
||||
DEFAULT_VENV_CANDIDATES,
|
||||
DatabaseEngine,
|
||||
DjangoProjectInfo,
|
||||
ProjectLayout,
|
||||
)
|
||||
|
||||
|
||||
class DjangoProjectDetector(IDjangoDetector):
|
||||
"""Concrete detector that inspects the filesystem for Django conventions."""
|
||||
|
||||
# Regex patterns for extracting settings values
|
||||
_STATIC_ROOT_RE = re.compile(
|
||||
r"""STATIC_ROOT\s*=\s*(?:"""
|
||||
r"""BASE_DIR\s*/\s*['"]([^'"]+)['"]""" # BASE_DIR / 'staticfiles'
|
||||
r"""|os\.path\.join\s*\(\s*BASE_DIR\s*,\s*['"]([^'"]+)['"]""" # os.path.join(...)
|
||||
r"""|['"]([^'"]+)['"]""" # plain string
|
||||
r""")""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
_DB_ENGINE_RE = re.compile(r"""['"]ENGINE['"]\s*:\s*['"]([^'"]+)['"]""")
|
||||
_WSGI_APP_RE = re.compile(r"""WSGI_APPLICATION\s*=\s*['"]([^'"]+)['"]""")
|
||||
|
||||
def detect(self, web_dir: Path, project_hint: Optional[str] = None) -> DjangoProjectInfo:
|
||||
"""Analyse *web_dir* and return a populated ``DjangoProjectInfo``."""
|
||||
info = DjangoProjectInfo()
|
||||
|
||||
if not web_dir.is_dir():
|
||||
info.errors.append(f"Web directory does not exist: {web_dir}")
|
||||
return info
|
||||
|
||||
# --- 1. Detect project layout & WSGI module ---
|
||||
self._detect_layout(web_dir, info, project_hint)
|
||||
|
||||
# --- 2. Locate virtualenv ---
|
||||
self._detect_venv(web_dir, info)
|
||||
|
||||
# --- 3. Inspect settings.py ---
|
||||
self._inspect_settings(web_dir, info)
|
||||
|
||||
# --- 4. Check for .env and requirements.txt ---
|
||||
info.has_dot_env = (web_dir / ".env").is_file()
|
||||
info.has_requirements = (web_dir / "requirements.txt").is_file()
|
||||
|
||||
return info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_layout(
|
||||
self,
|
||||
web_dir: Path,
|
||||
info: DjangoProjectInfo,
|
||||
project_hint: Optional[str],
|
||||
) -> None:
|
||||
"""Determine project layout and set ``wsgi_module``."""
|
||||
|
||||
# Priority 1: Modular layout (config/wsgi.py)
|
||||
if (web_dir / "config" / "wsgi.py").is_file():
|
||||
info.layout = ProjectLayout.MODULAR
|
||||
info.wsgi_module = "config.wsgi:application"
|
||||
info.settings_module = "config.settings"
|
||||
return
|
||||
|
||||
# Priority 2: Traditional layout (<project>/wsgi.py)
|
||||
if project_hint:
|
||||
candidate = web_dir / project_hint / "wsgi.py"
|
||||
if candidate.is_file():
|
||||
info.layout = ProjectLayout.TRADITIONAL
|
||||
info.wsgi_module = f"{project_hint}.wsgi:application"
|
||||
info.settings_module = f"{project_hint}.settings"
|
||||
return
|
||||
|
||||
# Auto-scan for traditional layout directories
|
||||
for child in sorted(web_dir.iterdir()):
|
||||
if child.is_dir() and (child / "wsgi.py").is_file():
|
||||
name = child.name
|
||||
info.layout = ProjectLayout.TRADITIONAL
|
||||
info.wsgi_module = f"{name}.wsgi:application"
|
||||
info.settings_module = f"{name}.settings"
|
||||
return
|
||||
|
||||
# Priority 3: Flat layout (wsgi.py at root)
|
||||
if (web_dir / "wsgi.py").is_file():
|
||||
info.layout = ProjectLayout.FLAT
|
||||
info.wsgi_module = "wsgi:application"
|
||||
# Try to read DJANGO_SETTINGS_MODULE from wsgi.py
|
||||
self._extract_settings_from_wsgi(web_dir / "wsgi.py", info)
|
||||
return
|
||||
|
||||
info.errors.append(
|
||||
"Could not locate wsgi.py — checked config/, <project>/, and root."
|
||||
)
|
||||
|
||||
def _detect_venv(self, web_dir: Path, info: DjangoProjectInfo) -> None:
|
||||
"""Find the first existing virtualenv candidate directory."""
|
||||
search_dirs = [web_dir, web_dir.parent] # public_html, then domain root
|
||||
|
||||
for base in search_dirs:
|
||||
for candidate_name in DEFAULT_VENV_CANDIDATES:
|
||||
candidate = base / candidate_name
|
||||
if (candidate / "bin" / "python").is_file() or \
|
||||
(candidate / "bin" / "python3").is_file():
|
||||
info.venv_path = candidate
|
||||
return
|
||||
|
||||
info.errors.append(
|
||||
f"No virtualenv found. Searched for {DEFAULT_VENV_CANDIDATES} "
|
||||
f"in {web_dir} and {web_dir.parent}."
|
||||
)
|
||||
|
||||
def _inspect_settings(self, web_dir: Path, info: DjangoProjectInfo) -> None:
|
||||
"""Parse ``settings.py`` for STATIC_ROOT and DATABASE ENGINE."""
|
||||
settings_path = self._find_settings_file(web_dir, info)
|
||||
if settings_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
content = settings_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
info.errors.append(f"Cannot read settings: {exc}")
|
||||
return
|
||||
|
||||
# STATIC_ROOT
|
||||
match = self._STATIC_ROOT_RE.search(content)
|
||||
if match:
|
||||
# Pick the first non-None group
|
||||
info.static_root_name = next(
|
||||
(g for g in match.groups() if g), DEFAULT_STATIC_DIR
|
||||
)
|
||||
|
||||
# Database engine
|
||||
db_match = self._DB_ENGINE_RE.search(content)
|
||||
if db_match:
|
||||
engine_str = db_match.group(1).lower()
|
||||
if "sqlite" in engine_str:
|
||||
info.database_engine = DatabaseEngine.SQLITE
|
||||
elif "postgresql" in engine_str or "postgis" in engine_str:
|
||||
info.database_engine = DatabaseEngine.POSTGRESQL
|
||||
elif "mysql" in engine_str:
|
||||
info.database_engine = DatabaseEngine.MYSQL
|
||||
else:
|
||||
info.database_engine = DatabaseEngine.OTHER
|
||||
|
||||
def _find_settings_file(
|
||||
self, web_dir: Path, info: DjangoProjectInfo
|
||||
) -> Optional[Path]:
|
||||
"""Locate settings.py based on the detected layout."""
|
||||
if info.settings_module:
|
||||
relative = info.settings_module.replace(".", "/") + ".py"
|
||||
candidate = web_dir / relative
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
# Fallback: search common locations
|
||||
for candidate in [
|
||||
web_dir / "config" / "settings.py",
|
||||
web_dir / "settings.py",
|
||||
]:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
# Search any subdirectory
|
||||
for child in sorted(web_dir.iterdir()):
|
||||
if child.is_dir():
|
||||
settings = child / "settings.py"
|
||||
if settings.is_file():
|
||||
return settings
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_settings_from_wsgi(wsgi_path: Path, info: DjangoProjectInfo) -> None:
|
||||
"""Try to read DJANGO_SETTINGS_MODULE from a flat wsgi.py."""
|
||||
try:
|
||||
content = wsgi_path.read_text(encoding="utf-8", errors="replace")
|
||||
match = re.search(
|
||||
r"""os\.environ.*?['"]DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([^'"]+)['"]""",
|
||||
content,
|
||||
)
|
||||
if match:
|
||||
info.settings_module = match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
287
core/doctor.py
Normal file
287
core/doctor.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Post-deployment health checker (doctor).
|
||||
|
||||
**Single Responsibility:** Run a suite of diagnostic checks against a live
|
||||
deployment and return a structured ``DiagnosticReport`` with colour-coded
|
||||
terminal output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
import grp
|
||||
except ImportError:
|
||||
grp = None # type: ignore
|
||||
|
||||
try:
|
||||
import pwd
|
||||
except ImportError:
|
||||
pwd = None # type: ignore
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from core.interfaces import IDeploymentDoctor, ISystemExecutor
|
||||
from core.models import (
|
||||
DiagnosticCheck,
|
||||
DiagnosticReport,
|
||||
DiagnosticStatus,
|
||||
DomainConfig,
|
||||
)
|
||||
|
||||
|
||||
class DeploymentDoctor(IDeploymentDoctor):
|
||||
"""Runs comprehensive health checks on a deployed Django/Gunicorn site."""
|
||||
|
||||
def __init__(self, executor: ISystemExecutor) -> None:
|
||||
self._executor = executor
|
||||
|
||||
def diagnose(self, domain_config: DomainConfig) -> DiagnosticReport:
|
||||
"""Run all health checks and return a ``DiagnosticReport``."""
|
||||
dc = domain_config
|
||||
report = DiagnosticReport(domain_config=dc)
|
||||
|
||||
self._check_service_status(dc, report)
|
||||
self._check_socket(dc, report)
|
||||
self._check_database_permissions(dc, report)
|
||||
self._check_static_files(dc, report)
|
||||
self._check_nginx_template(dc, report)
|
||||
self._check_socket_response(dc, report)
|
||||
|
||||
return report
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Individual checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_service_status(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Check if the Gunicorn systemd service is active."""
|
||||
rc, stdout, _ = self._executor.run(
|
||||
f"systemctl is-active {dc.service_name}", check=False
|
||||
)
|
||||
if rc == 0 and "active" in stdout:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Gunicorn Service",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"{dc.service_name} is active and running.",
|
||||
))
|
||||
else:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Gunicorn Service",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message=f"{dc.service_name} is NOT running.",
|
||||
suggestion=f"sudo systemctl start {dc.service_name}\n"
|
||||
f"journalctl -u {dc.service_name} -n 30",
|
||||
))
|
||||
|
||||
def _check_socket(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Verify unix socket exists with correct ownership and permissions."""
|
||||
sock = dc.socket_path
|
||||
|
||||
if not self._executor.file_exists(sock):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Unix Socket",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message=f"Socket not found: {sock}",
|
||||
suggestion=f"sudo systemctl restart {dc.service_name}",
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
st = os.stat(str(sock))
|
||||
owner = pwd.getpwuid(st.st_uid).pw_name
|
||||
group = grp.getgrgid(st.st_gid).gr_name
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
|
||||
issues = []
|
||||
if owner != dc.user:
|
||||
issues.append(f"Owner is '{owner}', expected '{dc.user}'")
|
||||
if group != "www-data":
|
||||
issues.append(f"Group is '{group}', expected 'www-data'")
|
||||
if mode & 0o060 != 0o060:
|
||||
issues.append(f"Mode is {oct(mode)}, group needs rw (0o660+)")
|
||||
|
||||
if issues:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Unix Socket",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message=f"Socket exists but has permission issues: {'; '.join(issues)}",
|
||||
suggestion=f"sudo chown {dc.user}:www-data {sock} && sudo chmod 660 {sock}",
|
||||
))
|
||||
else:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Unix Socket",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Socket OK — {owner}:{group} {oct(mode)}",
|
||||
))
|
||||
except (OSError, KeyError) as exc:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Unix Socket",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message=f"Could not inspect socket: {exc}",
|
||||
))
|
||||
|
||||
def _check_database_permissions(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Check db.sqlite3 is writable by www-data group."""
|
||||
db_path = dc.web_dir / "db.sqlite3"
|
||||
|
||||
if not self._executor.file_exists(db_path):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Database (SQLite)",
|
||||
status=DiagnosticStatus.OK,
|
||||
message="No db.sqlite3 found (possibly using PostgreSQL).",
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
st = os.stat(str(db_path))
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
group = grp.getgrgid(st.st_gid).gr_name if grp else "www-data"
|
||||
|
||||
if group != "www-data" or mode & 0o060 != 0o060:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Database (SQLite)",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message=f"db.sqlite3 permissions incorrect (group={group}, mode={oct(mode)}).",
|
||||
suggestion=f"sudo chown {dc.user}:www-data {db_path} && sudo chmod 664 {db_path}",
|
||||
))
|
||||
else:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Database (SQLite)",
|
||||
status=DiagnosticStatus.OK,
|
||||
message="db.sqlite3 permissions correct.",
|
||||
))
|
||||
except (OSError, KeyError):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Database (SQLite)",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message="Could not inspect db.sqlite3 permissions.",
|
||||
))
|
||||
|
||||
def _check_static_files(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Check if collectstatic has been run."""
|
||||
candidates = ["staticfiles", "static"]
|
||||
for name in candidates:
|
||||
static_dir = dc.web_dir / name
|
||||
if self._executor.file_exists(static_dir) and any(static_dir.iterdir()):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Static Files",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Static files found in {static_dir.name}/",
|
||||
))
|
||||
return
|
||||
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Static Files",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message="No static files found — Django Admin will have no styling.",
|
||||
suggestion="cd {web_dir} && source venv/bin/activate && "
|
||||
"python manage.py collectstatic --noinput".format(web_dir=dc.web_dir),
|
||||
))
|
||||
|
||||
def _check_nginx_template(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Check if NGINX config for this domain points to the correct socket."""
|
||||
nginx_conf = Path(f"/home/{dc.user}/conf/web/{dc.domain}/nginx.conf")
|
||||
|
||||
if not self._executor.file_exists(nginx_conf):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="NGINX Configuration",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message="NGINX domain config not found — template may not be applied.",
|
||||
suggestion=f"sudo v-rebuild-web-domain {dc.user} {dc.domain} yes",
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
content = nginx_conf.read_text(encoding="utf-8", errors="replace")
|
||||
expected_socket = str(dc.socket_path)
|
||||
if expected_socket in content:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="NGINX Configuration",
|
||||
status=DiagnosticStatus.OK,
|
||||
message="NGINX config points to the correct socket path.",
|
||||
))
|
||||
else:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="NGINX Configuration",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message="NGINX config does NOT point to the expected socket!",
|
||||
suggestion=f"sudo v-rebuild-web-domain {dc.user} {dc.domain} yes && "
|
||||
"sudo systemctl restart nginx",
|
||||
))
|
||||
except OSError:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="NGINX Configuration",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message="Could not read NGINX configuration.",
|
||||
))
|
||||
|
||||
def _check_socket_response(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Try to get an HTTP response through the unix socket."""
|
||||
sock = dc.socket_path
|
||||
if not self._executor.file_exists(sock):
|
||||
return # Already reported in _check_socket
|
||||
|
||||
rc, stdout, _ = self._executor.run(
|
||||
f"curl -s -o /dev/null -w '%{{http_code}}' "
|
||||
f"--unix-socket {sock} http://localhost/ --max-time 5",
|
||||
check=False,
|
||||
)
|
||||
if rc == 0 and stdout.strip("'\"") in ("200", "301", "302", "403"):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Socket Response",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Socket responds with HTTP {stdout.strip(chr(39))}.",
|
||||
))
|
||||
else:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Socket Response",
|
||||
status=DiagnosticStatus.WARNING,
|
||||
message=f"Socket did not respond as expected (rc={rc}, response={stdout}).",
|
||||
suggestion=f"journalctl -u {dc.service_name} -n 20",
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pretty-printer for terminal output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_report(report: DiagnosticReport) -> None:
|
||||
"""Print a colour-coded diagnostic report to the terminal."""
|
||||
RESET = "\033[0m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
RED = "\033[91m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
dc = report.domain_config
|
||||
print(f"\n{BOLD}╔══════════════════════════════════════════════════════╗{RESET}")
|
||||
print(f"{BOLD}║ Deployment Doctor — {dc.domain:<33}║{RESET}")
|
||||
print(f"{BOLD}╚══════════════════════════════════════════════════════╝{RESET}\n")
|
||||
|
||||
status_icons = {
|
||||
DiagnosticStatus.OK: f"{GREEN}✔{RESET}",
|
||||
DiagnosticStatus.WARNING: f"{YELLOW}⚠{RESET}",
|
||||
DiagnosticStatus.CRITICAL: f"{RED}✘{RESET}",
|
||||
}
|
||||
|
||||
for check in report.checks:
|
||||
icon = status_icons.get(check.status, "?")
|
||||
print(f" {icon} {BOLD}{check.name}{RESET}: {check.message}")
|
||||
if check.suggestion:
|
||||
print(f" └─ suggestion: {check.suggestion}")
|
||||
|
||||
print()
|
||||
if report.is_healthy:
|
||||
print(f" {GREEN}{BOLD}All checks passed! Your deployment is healthy.{RESET}\n")
|
||||
else:
|
||||
c = report.critical_count
|
||||
w = report.warning_count
|
||||
parts = []
|
||||
if c:
|
||||
parts.append(f"{RED}{c} critical{RESET}")
|
||||
if w:
|
||||
parts.append(f"{YELLOW}{w} warning(s){RESET}")
|
||||
print(f" Summary: {', '.join(parts)}\n")
|
||||
77
core/executor.py
Normal file
77
core/executor.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
System command executor with structured error handling.
|
||||
|
||||
**Single Responsibility:** Execute OS-level commands and manage files —
|
||||
nothing more. Provides a ``DryRunExecutor`` substitute for safe testing
|
||||
without side effects (Liskov Substitution Principle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from core.interfaces import ISystemExecutor
|
||||
|
||||
|
||||
class LinuxExecutor(ISystemExecutor):
|
||||
"""Production executor — runs real commands on the host system."""
|
||||
|
||||
def run(self, command: str, check: bool = True) -> Tuple[int, str, str]:
|
||||
"""Execute *command* via ``/bin/bash`` and return (rc, stdout, stderr)."""
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
executable="/bin/bash",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Command failed (rc={result.returncode}): {command}\n"
|
||||
f"stderr: {result.stderr.strip()}"
|
||||
)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
|
||||
def write_file(self, path: Path, content: str, mode: int = 0o644) -> None:
|
||||
"""Write *content* to *path* with the given POSIX *mode*."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(mode)
|
||||
|
||||
def file_exists(self, path: Path) -> bool:
|
||||
return path.exists()
|
||||
|
||||
def backup_file(self, path: Path) -> Optional[Path]:
|
||||
"""Create a timestamped backup of *path*."""
|
||||
if not path.exists():
|
||||
return None
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = path.with_suffix(f".bak.{timestamp}")
|
||||
shutil.copy2(str(path), str(backup_path))
|
||||
return backup_path
|
||||
|
||||
|
||||
class DryRunExecutor(ISystemExecutor):
|
||||
"""Test / dry-run executor — logs actions without side effects (LSP)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.log: list[str] = []
|
||||
|
||||
def run(self, command: str, check: bool = True) -> Tuple[int, str, str]:
|
||||
self.log.append(f"[DRY-RUN] exec: {command}")
|
||||
return 0, "", ""
|
||||
|
||||
def write_file(self, path: Path, content: str, mode: int = 0o644) -> None:
|
||||
self.log.append(f"[DRY-RUN] write: {path} (mode={oct(mode)}, {len(content)} bytes)")
|
||||
|
||||
def file_exists(self, path: Path) -> bool:
|
||||
return path.exists()
|
||||
|
||||
def backup_file(self, path: Path) -> Optional[Path]:
|
||||
self.log.append(f"[DRY-RUN] backup: {path}")
|
||||
return None
|
||||
89
core/interfaces.py
Normal file
89
core/interfaces.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Abstract interfaces (contracts) for the deployment engine.
|
||||
|
||||
Applying the **Interface Segregation Principle** (ISP) and
|
||||
**Dependency Inversion Principle** (DIP): every high-level orchestrator
|
||||
depends on these thin abstractions — never on concrete implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from core.models import (
|
||||
DiagnosticReport,
|
||||
DjangoProjectInfo,
|
||||
DomainConfig,
|
||||
ServiceConfig,
|
||||
)
|
||||
|
||||
|
||||
class IDjangoDetector(ABC):
|
||||
"""Inspects a Django project directory and returns structured metadata."""
|
||||
|
||||
@abstractmethod
|
||||
def detect(self, web_dir: Path, project_hint: Optional[str] = None) -> DjangoProjectInfo:
|
||||
"""Analyse *web_dir* and return a populated ``DjangoProjectInfo``."""
|
||||
|
||||
|
||||
class IServiceGenerator(ABC):
|
||||
"""Renders systemd service unit content from a ``ServiceConfig``."""
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, config: ServiceConfig) -> str:
|
||||
"""Return the full text of a systemd ``.service`` unit file."""
|
||||
|
||||
|
||||
class ISystemExecutor(ABC):
|
||||
"""Abstraction over OS-level command execution."""
|
||||
|
||||
@abstractmethod
|
||||
def run(self, command: str, check: bool = True) -> Tuple[int, str, str]:
|
||||
"""Execute *command* and return ``(return_code, stdout, stderr)``."""
|
||||
|
||||
@abstractmethod
|
||||
def write_file(self, path: Path, content: str, mode: int = 0o644) -> None:
|
||||
"""Write *content* to *path* with the given POSIX *mode*."""
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, path: Path) -> bool:
|
||||
"""Return ``True`` if *path* exists on the filesystem."""
|
||||
|
||||
@abstractmethod
|
||||
def backup_file(self, path: Path) -> Optional[Path]:
|
||||
"""Create a timestamped backup of *path* and return the backup path."""
|
||||
|
||||
|
||||
class INginxManager(ABC):
|
||||
"""Manages HestiaCP NGINX proxy template installation."""
|
||||
|
||||
@abstractmethod
|
||||
def install_templates(self, tpl_source: Path, stpl_source: Path) -> None:
|
||||
"""Copy templates to the HestiaCP template directory and set permissions."""
|
||||
|
||||
@abstractmethod
|
||||
def rebuild_domain(self, domain_config: DomainConfig) -> None:
|
||||
"""Force-rebuild the NGINX configuration for a domain."""
|
||||
|
||||
|
||||
class IProjectMigrator(ABC):
|
||||
"""Upgrades / repairs a previously deployed project in-place."""
|
||||
|
||||
@abstractmethod
|
||||
def needs_migration(self, domain_config: DomainConfig) -> bool:
|
||||
"""Return ``True`` if the existing deployment requires migration."""
|
||||
|
||||
@abstractmethod
|
||||
def migrate(self, domain_config: DomainConfig) -> DiagnosticReport:
|
||||
"""Perform the migration / repair and return a diagnostic report."""
|
||||
|
||||
|
||||
class IDeploymentDoctor(ABC):
|
||||
"""Runs post-deployment health checks."""
|
||||
|
||||
@abstractmethod
|
||||
def diagnose(self, domain_config: DomainConfig) -> DiagnosticReport:
|
||||
"""Run all health checks and return a ``DiagnosticReport``."""
|
||||
200
core/migrator.py
Normal file
200
core/migrator.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Project migrator — upgrades and repairs previously deployed projects.
|
||||
|
||||
**Single Responsibility:** Scan an existing systemd service file, detect
|
||||
misconfigurations (wrong socket path, missing ``--umask``, incorrect WSGI
|
||||
module), and apply corrective changes without downtime where possible.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.detector import DjangoProjectDetector
|
||||
from core.interfaces import IProjectMigrator, IServiceGenerator, ISystemExecutor
|
||||
from core.models import (
|
||||
DiagnosticCheck,
|
||||
DiagnosticReport,
|
||||
DiagnosticStatus,
|
||||
DjangoProjectInfo,
|
||||
DomainConfig,
|
||||
ServiceConfig,
|
||||
)
|
||||
|
||||
|
||||
class ProjectMigrator(IProjectMigrator):
|
||||
"""Scans and repairs previously deployed Gunicorn services."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor: ISystemExecutor,
|
||||
detector: DjangoProjectDetector,
|
||||
generator: IServiceGenerator,
|
||||
) -> None:
|
||||
self._executor = executor
|
||||
self._detector = detector
|
||||
self._generator = generator
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def needs_migration(self, domain_config: DomainConfig) -> bool:
|
||||
"""Return ``True`` if the existing deployment has known issues."""
|
||||
service_file = domain_config.service_file
|
||||
if not self._executor.file_exists(service_file):
|
||||
return False
|
||||
|
||||
content = service_file.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
checks = [
|
||||
"--umask" not in content,
|
||||
self._has_socket_mismatch(content, domain_config),
|
||||
self._has_wsgi_mismatch(content, domain_config),
|
||||
"ProtectSystem" not in content,
|
||||
]
|
||||
return any(checks)
|
||||
|
||||
def migrate(self, domain_config: DomainConfig) -> DiagnosticReport:
|
||||
"""Perform the migration / repair and return a diagnostic report."""
|
||||
report = DiagnosticReport(domain_config=domain_config)
|
||||
dc = domain_config
|
||||
|
||||
# --- Step 1: Read existing service file ---
|
||||
if not self._executor.file_exists(dc.service_file):
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Service File",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message=f"Service file not found: {dc.service_file}",
|
||||
suggestion="Run 'setup' command first to create the service.",
|
||||
))
|
||||
return report
|
||||
|
||||
# --- Step 2: Backup existing service file ---
|
||||
backup_path = self._executor.backup_file(dc.service_file)
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Backup",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Backed up to: {backup_path}",
|
||||
))
|
||||
|
||||
# --- Step 3: Re-detect the project (smart detection) ---
|
||||
project_info = self._detector.detect(dc.web_dir)
|
||||
if not project_info.is_valid:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Project Detection",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message="Could not detect project structure.",
|
||||
suggestion=f"Errors: {'; '.join(project_info.errors)}",
|
||||
))
|
||||
return report
|
||||
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Project Detection",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Layout: {project_info.layout.name}, "
|
||||
f"WSGI: {project_info.wsgi_module}",
|
||||
))
|
||||
|
||||
# --- Step 4: Generate corrected service file ---
|
||||
service_config = ServiceConfig(
|
||||
domain_config=dc,
|
||||
project_info=project_info,
|
||||
)
|
||||
new_content = self._generator.generate(service_config)
|
||||
self._executor.write_file(dc.service_file, new_content, mode=0o644)
|
||||
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Service File Update",
|
||||
status=DiagnosticStatus.OK,
|
||||
message="Service file regenerated with --umask 007, hardened security, "
|
||||
f"and correct WSGI module ({project_info.wsgi_module}).",
|
||||
))
|
||||
|
||||
# --- Step 5: Fix file permissions ---
|
||||
self._fix_permissions(dc, project_info, report)
|
||||
|
||||
# --- Step 6: Reload and restart ---
|
||||
self._restart_service(dc, report)
|
||||
|
||||
return report
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _has_socket_mismatch(self, content: str, dc: DomainConfig) -> bool:
|
||||
"""Check if the socket path in the service file differs from canonical."""
|
||||
match = re.search(r"unix:(\S+)", content)
|
||||
if match:
|
||||
existing = Path(match.group(1))
|
||||
return existing != dc.socket_path
|
||||
return True
|
||||
|
||||
def _has_wsgi_mismatch(self, content: str, dc: DomainConfig) -> bool:
|
||||
"""Check if the WSGI module might be wrong for the current project."""
|
||||
project_info = self._detector.detect(dc.web_dir)
|
||||
if not project_info.is_valid:
|
||||
return False
|
||||
return project_info.wsgi_module not in content
|
||||
|
||||
def _fix_permissions(
|
||||
self,
|
||||
dc: DomainConfig,
|
||||
project_info: DjangoProjectInfo,
|
||||
report: DiagnosticReport,
|
||||
) -> None:
|
||||
"""Fix ownership and permissions on db.sqlite3 and static directory."""
|
||||
web_dir = dc.web_dir
|
||||
|
||||
# Fix db.sqlite3
|
||||
db_file = web_dir / "db.sqlite3"
|
||||
if self._executor.file_exists(db_file):
|
||||
self._executor.run(
|
||||
f"chown {dc.user}:www-data {db_file} && chmod 664 {db_file}",
|
||||
check=False,
|
||||
)
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Database Permissions",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Set {dc.user}:www-data 664 on db.sqlite3",
|
||||
))
|
||||
|
||||
# Fix static root directory
|
||||
static_dir = web_dir / project_info.static_root_name
|
||||
if self._executor.file_exists(static_dir):
|
||||
self._executor.run(
|
||||
f"chown -R {dc.user}:www-data {static_dir}",
|
||||
check=False,
|
||||
)
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Static Files Permissions",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"Fixed ownership on {static_dir}",
|
||||
))
|
||||
|
||||
def _restart_service(self, dc: DomainConfig, report: DiagnosticReport) -> None:
|
||||
"""Reload systemd daemon and restart the Gunicorn service."""
|
||||
self._executor.run("systemctl daemon-reload", check=False)
|
||||
rc, _, stderr = self._executor.run(
|
||||
f"systemctl restart {dc.service_name}", check=False
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Service Restart",
|
||||
status=DiagnosticStatus.OK,
|
||||
message=f"{dc.service_name} restarted successfully.",
|
||||
))
|
||||
else:
|
||||
report.checks.append(DiagnosticCheck(
|
||||
name="Service Restart",
|
||||
status=DiagnosticStatus.CRITICAL,
|
||||
message=f"Failed to restart {dc.service_name}.",
|
||||
suggestion=f"Check logs: journalctl -u {dc.service_name} -n 30\n"
|
||||
f"Error: {stderr}",
|
||||
))
|
||||
184
core/models.py
Normal file
184
core/models.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# -*- 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)
|
||||
59
core/nginx_manager.py
Normal file
59
core/nginx_manager.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
NGINX template manager for HestiaCP.
|
||||
|
||||
**Single Responsibility:** Install, validate, and manage the custom NGINX
|
||||
proxy templates that HestiaCP uses to route traffic to the Gunicorn socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from core.interfaces import INginxManager, ISystemExecutor
|
||||
from core.models import HESTIA_TEMPLATE_DIR, TEMPLATE_NAME, DomainConfig
|
||||
|
||||
|
||||
class HestiaNginxManager(INginxManager):
|
||||
"""Manages NGINX template deployment for HestiaCP."""
|
||||
|
||||
def __init__(self, executor: ISystemExecutor) -> None:
|
||||
self._executor = executor
|
||||
|
||||
@property
|
||||
def tpl_dest(self) -> Path:
|
||||
return HESTIA_TEMPLATE_DIR / f"{TEMPLATE_NAME}.tpl"
|
||||
|
||||
@property
|
||||
def stpl_dest(self) -> Path:
|
||||
return HESTIA_TEMPLATE_DIR / f"{TEMPLATE_NAME}.stpl"
|
||||
|
||||
def install_templates(self, tpl_source: Path, stpl_source: Path) -> None:
|
||||
"""Copy templates to the HestiaCP template directory and set permissions."""
|
||||
for src, dst in [(tpl_source, self.tpl_dest), (stpl_source, self.stpl_dest)]:
|
||||
if not self._executor.file_exists(src):
|
||||
raise FileNotFoundError(f"Template source not found: {src}")
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
self._executor.write_file(dst, content, mode=0o644)
|
||||
|
||||
# Set ownership to root:root
|
||||
self._executor.run(
|
||||
f"chown root:root {self.tpl_dest} {self.stpl_dest}"
|
||||
)
|
||||
|
||||
def rebuild_domain(self, domain_config: DomainConfig) -> None:
|
||||
"""Force-rebuild the NGINX configuration for a domain and restart."""
|
||||
dc = domain_config
|
||||
self._executor.run(
|
||||
f"v-rebuild-web-domain {dc.user} {dc.domain} yes",
|
||||
check=False,
|
||||
)
|
||||
self._executor.run("systemctl restart nginx", check=False)
|
||||
|
||||
def is_template_installed(self) -> bool:
|
||||
"""Check if both template files exist in the HestiaCP directory."""
|
||||
return (
|
||||
self._executor.file_exists(self.tpl_dest)
|
||||
and self._executor.file_exists(self.stpl_dest)
|
||||
)
|
||||
84
core/service_generator.py
Normal file
84
core/service_generator.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Systemd service unit file generator for Gunicorn.
|
||||
|
||||
**Single Responsibility:** Accept a ``ServiceConfig`` and render a production-
|
||||
hardened systemd ``.service`` file. Security best-practices (``--umask 007``,
|
||||
``ProtectSystem``, dynamic worker count) are baked in by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
from core.interfaces import IServiceGenerator
|
||||
from core.models import ServiceConfig
|
||||
|
||||
|
||||
class SystemdServiceGenerator(IServiceGenerator):
|
||||
"""Generates a systemd service unit for a Gunicorn-backed Django app."""
|
||||
|
||||
_TEMPLATE = textwrap.dedent("""\
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Gunicorn service for {domain}
|
||||
# Auto-generated by hestia-django-gunicorn deployment engine
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
[Unit]
|
||||
Description=Gunicorn instance for {domain} (Django)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User={user}
|
||||
Group=www-data
|
||||
WorkingDirectory={web_dir}
|
||||
Environment="PATH={venv_bin}:/usr/local/bin:/usr/bin"
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
{env_file_line}
|
||||
ExecStart={venv_bin}/gunicorn \\
|
||||
--access-logfile - \\
|
||||
--error-logfile - \\
|
||||
--workers {workers} \\
|
||||
--threads {threads} \\
|
||||
--worker-class=gthread \\
|
||||
--bind unix:{socket_path} \\
|
||||
--umask {umask} \\
|
||||
--log-level={log_level} \\
|
||||
{wsgi_module}
|
||||
|
||||
Restart=always
|
||||
RestartSec={restart_sec}
|
||||
ProtectSystem=full
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
""")
|
||||
|
||||
def generate(self, config: ServiceConfig) -> str:
|
||||
"""Return the full text of a systemd ``.service`` unit file."""
|
||||
dc = config.domain_config
|
||||
pi = config.project_info
|
||||
|
||||
venv_bin = str(pi.venv_path / "bin") if pi.venv_path else "/usr/bin"
|
||||
|
||||
# Optional EnvironmentFile directive for projects with .env
|
||||
if pi.has_dot_env:
|
||||
env_file_line = f"EnvironmentFile={dc.web_dir}/.env"
|
||||
else:
|
||||
env_file_line = "# No .env found — environment variables from Environment= only"
|
||||
|
||||
return self._TEMPLATE.format(
|
||||
domain=dc.domain,
|
||||
user=dc.user,
|
||||
web_dir=dc.web_dir,
|
||||
venv_bin=venv_bin,
|
||||
socket_path=dc.socket_path,
|
||||
workers=config.workers,
|
||||
threads=config.threads,
|
||||
umask=config.umask,
|
||||
log_level=config.log_level,
|
||||
restart_sec=config.restart_sec,
|
||||
wsgi_module=pi.wsgi_module,
|
||||
env_file_line=env_file_line,
|
||||
)
|
||||
Reference in New Issue
Block a user