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:
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
|
||||
Reference in New Issue
Block a user